Compare commits
283 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4db600c9 | ||
|
|
02879e2645 | ||
|
|
00b56ecefd | ||
|
|
47dd18a0b5 | ||
|
|
1a708ebca2 | ||
|
|
5f8e62dad0 | ||
|
|
b74f7758dc | ||
|
|
899c19b2d7 | ||
|
|
35038a63c4 | ||
|
|
7b2af3c514 | ||
|
|
4ab7428599 | ||
|
|
be88af5d48 | ||
|
|
5bb3f6d0a9 | ||
|
|
17c14a7243 | ||
|
|
f44d4055e6 | ||
|
|
38054f57e5 | ||
|
|
33ce0e99b5 | ||
|
|
b5e6466c1d | ||
|
|
f89ecd5c64 | ||
|
|
e434178a5c | ||
|
|
7a3ee1b557 | ||
|
|
e51abaf5bd | ||
|
|
46d6d6c733 | ||
|
|
d9f86f1155 | ||
|
|
01484832fc | ||
|
|
4857b43771 | ||
|
|
52d7cb6f04 | ||
|
|
5c6bf84106 | ||
|
|
c84ae1cd55 | ||
|
|
daf8e3a16f | ||
|
|
df719958cf | ||
|
|
2be81b8e1a | ||
|
|
4bed86dec9 | ||
|
|
072cf20cc6 | ||
|
|
cca421e283 | ||
|
|
a044f1d274 | ||
|
|
9de6334f21 | ||
|
|
f01b66f083 | ||
|
|
262d436533 | ||
|
|
b8ab17eee1 | ||
|
|
7bbd7bcee3 | ||
|
|
4865d12147 | ||
|
|
0713ca1c1a | ||
|
|
dce202d0be | ||
|
|
4673d147db | ||
|
|
0943c45ae6 | ||
|
|
798c4248ff | ||
|
|
1bce4e727e | ||
|
|
1aa94a3365 | ||
|
|
319f460553 | ||
|
|
7daf2b5cac | ||
|
|
f0fc275f67 | ||
|
|
04a682eddc | ||
|
|
2fb807632c | ||
|
|
4b339bca37 | ||
|
|
09c3a6c72b | ||
|
|
755c8bb43a | ||
|
|
9d4a639f31 | ||
|
|
60e6b2b039 | ||
|
|
37f3f964ea | ||
|
|
4a1a8ff380 | ||
|
|
6787fed062 | ||
|
|
ab2bee9c4b | ||
|
|
d1ced94030 | ||
|
|
a3e35c862c | ||
|
|
0193a200b8 | ||
|
|
7224464202 | ||
|
|
c457aadcab | ||
|
|
f38b12d55b | ||
|
|
e4c9ad8796 | ||
|
|
5be1465b13 | ||
|
|
7215aa4bd6 | ||
|
|
829a9c2679 | ||
|
|
dfcdc57a18 | ||
|
|
43e8c57701 | ||
|
|
076f5c45e8 | ||
|
|
6d3c3d0892 | ||
|
|
e620fd1214 | ||
|
|
5807d76c2f | ||
|
|
017847240d | ||
|
|
c873b0ba0c | ||
|
|
6f3150d936 | ||
|
|
0792f5490b | ||
|
|
326ea1c3d1 | ||
|
|
fea95b8479 | ||
|
|
6d64c3c250 | ||
|
|
2b9d3e99d3 | ||
|
|
9a5f245440 | ||
|
|
793172c783 | ||
|
|
9f343bacf7 | ||
|
|
c31cb7540d | ||
|
|
f9efa28223 | ||
|
|
2cbb35fe3b | ||
|
|
f23fcbedb8 | ||
|
|
ad10f975b4 | ||
|
|
1c03524ca8 | ||
|
|
4af135d1fb | ||
|
|
93b5a867bb | ||
|
|
f899f41d16 | ||
|
|
ab52676f23 | ||
|
|
27fc784411 | ||
|
|
d929c09c56 | ||
|
|
cff06e38cb | ||
|
|
5b1aeaeb0c | ||
|
|
90e9b55109 | ||
|
|
cf9c00a2ad | ||
|
|
fbdb5a3f0f | ||
|
|
dde930bed7 | ||
|
|
a9fc876173 | ||
|
|
08b31ba263 | ||
|
|
9ede992e4e | ||
|
|
dcb997f501 | ||
|
|
c8efdac23a | ||
|
|
e307d1ab35 | ||
|
|
e6c6b4e06f | ||
|
|
5843c58a36 | ||
|
|
5281f8068d | ||
|
|
86d5dabf90 | ||
|
|
a81c81e42c | ||
|
|
bec2820969 | ||
|
|
0bf2271a73 | ||
|
|
bd4b91bbbd | ||
|
|
fdec317df0 | ||
|
|
8970ad5ad5 | ||
|
|
c4255e65bc | ||
|
|
fcf046cbe8 | ||
|
|
6932edc6d0 | ||
|
|
3f961a7408 | ||
|
|
4d0f3b6997 | ||
|
|
5a06599d96 | ||
|
|
d2a73a3590 | ||
|
|
932ecc436a | ||
|
|
1613274cb0 | ||
|
|
0b4720d94b | ||
|
|
16df341581 | ||
|
|
a848776a34 | ||
|
|
681b1c63f1 | ||
|
|
51a4b63fb5 | ||
|
|
3a7977d086 | ||
|
|
c682520dd9 | ||
|
|
24b7258338 | ||
|
|
89e6e4abd8 | ||
|
|
4700f54798 | ||
|
|
9ca4442e6a | ||
|
|
ce6f58f403 | ||
|
|
c466542990 | ||
|
|
9cb8c37298 | ||
|
|
f6f7e15735 | ||
|
|
c712133df0 | ||
|
|
fc016bd682 | ||
|
|
0e586e4152 | ||
|
|
ea425773e0 | ||
|
|
10949b11f4 | ||
|
|
0e022d04b1 | ||
|
|
3319e158b5 | ||
|
|
f467a77ae2 | ||
|
|
56048725e4 | ||
|
|
425c1d3674 | ||
|
|
8838f6f2ad | ||
|
|
139a78b2f6 | ||
|
|
dd5e3ee7ee | ||
|
|
9f8f7bb45e | ||
|
|
27e246859e | ||
|
|
f1688ac87a | ||
|
|
54779e1db8 | ||
|
|
be9087bee3 | ||
|
|
4ab5724fc1 | ||
|
|
45a47940ad | ||
|
|
1777d69495 | ||
|
|
8676b83fe3 | ||
|
|
b67701ff6d | ||
|
|
eb9acef9b5 | ||
|
|
00aec70fb8 | ||
|
|
18d28fc362 | ||
|
|
eb3545e994 | ||
|
|
ad71c8db34 | ||
|
|
6da281bf4e | ||
|
|
3dd8ba1a99 | ||
|
|
2503d21522 | ||
|
|
36a3419aec | ||
|
|
7353fad809 | ||
|
|
b5a26caa08 | ||
|
|
d7206546af | ||
|
|
4fa86a2c46 | ||
|
|
a6ed23b169 | ||
|
|
d9201c5084 | ||
|
|
d0ba8261e3 | ||
|
|
f89447badc | ||
|
|
14c42f6e6d | ||
|
|
7a05bdcb82 | ||
|
|
5eb7763052 | ||
|
|
8c73ae6035 | ||
|
|
6954e9dde7 | ||
|
|
f6336eac4e | ||
|
|
0331c18401 | ||
|
|
1f8fd29dad | ||
|
|
5877c4b2be | ||
|
|
c6d0809ecc | ||
|
|
cf8a601104 | ||
|
|
96a0eebc0c | ||
|
|
2af3425b9e | ||
|
|
31bf2aeb80 | ||
|
|
787f6f0d74 | ||
|
|
17a431321c | ||
|
|
05e9add16d | ||
|
|
c4ef56511d | ||
|
|
cfa2c8ef6f | ||
|
|
f36b6863ce | ||
|
|
24482cf7a0 | ||
|
|
d661a0ea6d | ||
|
|
a0ec6941ab | ||
|
|
5e711fb3b9 | ||
|
|
ab66e7ec8a | ||
|
|
08aba6cd51 | ||
|
|
d3805cd77a | ||
|
|
dd70136e6c | ||
|
|
a94c480c22 | ||
|
|
10fd4ecd6b | ||
|
|
9287e2f9e2 | ||
|
|
257f859825 | ||
|
|
3a4ab62ddd | ||
|
|
a4e9d8e9b0 | ||
|
|
3be6d04d29 | ||
|
|
b59ff6f89e | ||
|
|
813fea93ee | ||
|
|
8f50e44b45 | ||
|
|
fb2448c15a | ||
|
|
db575aad13 | ||
|
|
6ed93d4b82 | ||
|
|
634123d723 | ||
|
|
75c25ac053 | ||
|
|
8088736d6e | ||
|
|
6c45f5b99c | ||
|
|
422eaa6d37 | ||
|
|
c423afb0bf | ||
|
|
835f768337 | ||
|
|
b3d0e54af2 | ||
|
|
1451cdfa64 | ||
|
|
53cc9d88e5 | ||
|
|
a6bc0039e9 | ||
|
|
adbc2c5ad7 | ||
|
|
154bc7dbc6 | ||
|
|
2d3fe9795f | ||
|
|
d19f564e4e | ||
|
|
babe7b0be9 | ||
|
|
dee04945d0 | ||
|
|
bf455fb7cc | ||
|
|
dfd2f7943f | ||
|
|
fece11540b | ||
|
|
ac43ef4ab7 | ||
|
|
bc25fea1c0 | ||
|
|
30cb7b6ec8 | ||
|
|
289d834587 | ||
|
|
428e415616 | ||
|
|
0d284c2494 | ||
|
|
4a46a5ae9e | ||
|
|
df3a2016ff | ||
|
|
dda83761b5 | ||
|
|
882444e0d5 | ||
|
|
fa4736c672 | ||
|
|
dc173b29bc | ||
|
|
c3a4ce1eb4 | ||
|
|
044f0454f8 | ||
|
|
9bd5c38a96 | ||
|
|
d6b4c2394a | ||
|
|
9fe4678193 | ||
|
|
f41560cd3e | ||
|
|
d7de795a9f | ||
|
|
f79e87844b | ||
|
|
c57a930bf3 | ||
|
|
d86afb2381 | ||
|
|
d69df41ef0 | ||
|
|
cbfdc359d3 | ||
|
|
f3822a949d | ||
|
|
db5fc8bc11 | ||
|
|
7a68920889 | ||
|
|
effad21c64 | ||
|
|
dafd547656 | ||
|
|
20487790ca | ||
|
|
b58094e10b | ||
|
|
bacf7d841b | ||
|
|
06ef7f9efe | ||
|
|
bfbe928173 |
@@ -1,4 +1,4 @@
|
|||||||
examples
|
.examples
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.github
|
.github
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
41
.examples/docker-compose-grafana-prometheus/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
## Usage
|
||||||
|
Gatus exposes Prometheus metrics at `/metrics` if the `metrics` configuration option is set to `true`.
|
||||||
|
|
||||||
|
To run this example, all you need to do is execute the following command:
|
||||||
|
```console
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
Once you've done the above, you should be able to access the Grafana dashboard at `http://localhost:3000`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## Queries
|
||||||
|
By default, this example has a Grafana dashboard with some panels, but for the sake of verbosity, you'll find
|
||||||
|
a list of simple queries below. Those make use of the `key` parameter, which is a concatenation of the endpoint's
|
||||||
|
group and name.
|
||||||
|
|
||||||
|
### Success rate
|
||||||
|
```
|
||||||
|
sum(rate(gatus_results_total{success="true"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response time
|
||||||
|
```
|
||||||
|
gatus_results_duration_seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Total results per minute
|
||||||
|
```
|
||||||
|
sum(rate(gatus_results_total[5m])*60) by (key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Total successful results per minute
|
||||||
|
```
|
||||||
|
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Total unsuccessful results per minute
|
||||||
|
```
|
||||||
|
sum(rate(gatus_results_total{success="false"}[5m])*60) by (key)
|
||||||
|
```
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
metrics: true
|
metrics: true
|
||||||
services:
|
endpoints:
|
||||||
- name: TwiNNatioN
|
- name: website
|
||||||
url: https://twinnation.org/health
|
url: https://twin.sh/health
|
||||||
interval: 30s
|
|
||||||
conditions:
|
|
||||||
- "[STATUS] == 200"
|
|
||||||
- name: GitHub
|
|
||||||
url: https://api.github.com/healthz
|
|
||||||
interval: 5m
|
interval: 5m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
- name: Example
|
|
||||||
|
- name: example
|
||||||
url: https://example.com/
|
url: https://example.com/
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: github
|
||||||
|
url: https://api.github.com/healthz
|
||||||
|
interval: 5m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
version: '3.7'
|
version: "3.9"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
gatus:
|
gatus:
|
||||||
container_name: gatus
|
container_name: gatus
|
||||||
image: twinproduction/gatus
|
image: twinproduction/gatus
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
networks:
|
networks:
|
||||||
- metrics
|
- metrics
|
||||||
|
|
||||||
@@ -18,7 +17,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
command: --config.file=/etc/prometheus/prometheus.yml
|
command: --config.file=/etc/prometheus/prometheus.yml
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- "9090:9090"
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
networks:
|
networks:
|
||||||
@@ -31,7 +30,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
GF_SECURITY_ADMIN_PASSWORD: secret
|
GF_SECURITY_ADMIN_PASSWORD: secret
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./grafana/grafana.ini/:/etc/grafana/grafana.ini:ro
|
- ./grafana/grafana.ini/:/etc/grafana/grafana.ini:ro
|
||||||
- ./grafana/provisioning/:/etc/grafana/provisioning/:ro
|
- ./grafana/provisioning/:/etc/grafana/provisioning/:ro
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": "-- Grafana --",
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"gnetId": null,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": 3,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"cacheTimeout": null,
|
||||||
|
"datasource": null,
|
||||||
|
"description": "Number of successful results compared to the total number of results during the current interval",
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 9,
|
||||||
|
"links": [],
|
||||||
|
"options": {
|
||||||
|
"fieldOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"mean"
|
||||||
|
],
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"op": "=",
|
||||||
|
"text": "N/A",
|
||||||
|
"type": 1,
|
||||||
|
"value": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max": 1,
|
||||||
|
"min": 0,
|
||||||
|
"nullValueMode": "connected",
|
||||||
|
"thresholds": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "semi-dark-orange",
|
||||||
|
"value": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "yellow",
|
||||||
|
"value": 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "dark-green",
|
||||||
|
"value": 0.95
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unit": "percentunit"
|
||||||
|
},
|
||||||
|
"override": {},
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"showThresholdLabels": false,
|
||||||
|
"showThresholdMarkers": false
|
||||||
|
},
|
||||||
|
"pluginVersion": "6.4.4",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total{success=\"true\"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)",
|
||||||
|
"hide": false,
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Success rate",
|
||||||
|
"type": "gauge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"cacheTimeout": null,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 11,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "null as zero",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pluginVersion": "6.4.4",
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "gatus_results_duration_seconds",
|
||||||
|
"format": "time_series",
|
||||||
|
"instant": false,
|
||||||
|
"interval": "",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Response time",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"cacheTimeout": null,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 8
|
||||||
|
},
|
||||||
|
"id": 10,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "connected",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pluginVersion": "6.4.4",
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": true,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total{success=\"true\"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)",
|
||||||
|
"format": "time_series",
|
||||||
|
"instant": false,
|
||||||
|
"interval": "",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Success rate",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"description": "Number of results per minute",
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 8
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"interval": "",
|
||||||
|
"legend": {
|
||||||
|
"alignAsTable": false,
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"hideEmpty": false,
|
||||||
|
"hideZero": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"rightSide": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total[5m])*60) by (key)",
|
||||||
|
"format": "time_series",
|
||||||
|
"hide": false,
|
||||||
|
"instant": false,
|
||||||
|
"interval": "30s",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Total results per minute",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"decimals": null,
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"id": 5,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total{success=\"true\"}[5m])*60) by (key)",
|
||||||
|
"instant": false,
|
||||||
|
"interval": "30s",
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Successful results per minute",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"decimals": null,
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total{success=\"false\"}[5m])*60) by (key)",
|
||||||
|
"interval": "30s",
|
||||||
|
"legendFormat": "{{key}} ",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Unsuccessful results per minute",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "1m",
|
||||||
|
"schemaVersion": 20,
|
||||||
|
"style": "dark",
|
||||||
|
"tags": [],
|
||||||
|
"templating": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {
|
||||||
|
"refresh_intervals": [
|
||||||
|
"5s",
|
||||||
|
"10s",
|
||||||
|
"30s",
|
||||||
|
"1m",
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"2h",
|
||||||
|
"1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timezone": "",
|
||||||
|
"title": "Gatus",
|
||||||
|
"uid": "KPI7Qj1Wk",
|
||||||
|
"version": 2
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@ alerting:
|
|||||||
webhook-url: "http://mattermost:8065/hooks/tokengoeshere"
|
webhook-url: "http://mattermost:8065/hooks/tokengoeshere"
|
||||||
insecure: true
|
insecure: true
|
||||||
|
|
||||||
services:
|
endpoints:
|
||||||
- name: example
|
- name: example
|
||||||
url: http://example.org
|
url: https://example.org
|
||||||
interval: 1m
|
interval: 1m
|
||||||
alerts:
|
alerts:
|
||||||
- type: mattermost
|
- type: mattermost
|
||||||
enabled: true
|
enabled: true
|
||||||
description: "healthcheck failed 3 times in a row"
|
description: "health check failed 3 times in a row"
|
||||||
send-on-resolved: true
|
send-on-resolved: true
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
version: "3.8"
|
version: "3.9"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
gatus:
|
gatus:
|
||||||
container_name: gatus
|
container_name: gatus
|
||||||
image: twinproduction/gatus:latest
|
image: twinproduction/gatus:latest
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ services:
|
|||||||
container_name: mattermost
|
container_name: mattermost
|
||||||
image: mattermost/mattermost-preview:5.26.0
|
image: mattermost/mattermost-preview:5.26.0
|
||||||
ports:
|
ports:
|
||||||
- 8065:8065
|
- "8065:8065"
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|
||||||
42
.examples/docker-compose-postgres-storage/config/config.yaml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
storage:
|
||||||
|
type: postgres
|
||||||
|
path: "postgres://username:password@postgres:5432/gatus?sslmode=disable"
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: back-end
|
||||||
|
group: core
|
||||||
|
url: "https://example.org/"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[CERTIFICATE_EXPIRATION] > 48h"
|
||||||
|
|
||||||
|
- 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: example-dns-query
|
||||||
|
url: "1.1.1.1" # Address of the DNS server to use
|
||||||
|
interval: 5m
|
||||||
|
dns:
|
||||||
|
query-name: "example.com"
|
||||||
|
query-type: "A"
|
||||||
|
conditions:
|
||||||
|
- "[BODY] == 93.184.216.34"
|
||||||
|
- "[DNS_RCODE] == NOERROR"
|
||||||
|
|
||||||
|
- name: icmp-ping
|
||||||
|
url: "icmp://example.org"
|
||||||
|
interval: 1m
|
||||||
|
conditions:
|
||||||
|
- "[CONNECTED] == true"
|
||||||
29
.examples/docker-compose-postgres-storage/docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
volumes:
|
||||||
|
- ./data/db:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=gatus
|
||||||
|
- POSTGRES_USER=username
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
gatus:
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
storage:
|
storage:
|
||||||
type: sqlite
|
type: sqlite
|
||||||
file: /data/data.db
|
path: /data/data.db
|
||||||
|
|
||||||
services:
|
endpoints:
|
||||||
- name: back-end
|
- name: back-end
|
||||||
group: core
|
group: core
|
||||||
url: "https://example.org/"
|
url: "https://example.org/"
|
||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
- name: example-dns-query
|
- name: example-dns-query
|
||||||
url: "8.8.8.8" # Address of the DNS server to use
|
url: "1.1.1.1" # Address of the DNS server to use
|
||||||
interval: 5m
|
interval: 5m
|
||||||
dns:
|
dns:
|
||||||
query-name: "example.com"
|
query-name: "example.com"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
gatus:
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
|
- ./data:/data/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
endpoints:
|
||||||
- name: example
|
- name: example
|
||||||
url: http://example.org
|
url: https://example.org
|
||||||
interval: 30s
|
interval: 30s
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
@@ -5,4 +5,4 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
endpoints:
|
||||||
- name: example
|
- name: example
|
||||||
url: http://example.org
|
url: https://example.org
|
||||||
interval: 30s
|
interval: 30s
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: gatus
|
||||||
|
namespace: kube-system
|
||||||
data:
|
data:
|
||||||
config.yaml: |
|
config.yaml: |
|
||||||
metrics: true
|
metrics: true
|
||||||
services:
|
endpoints:
|
||||||
- name: TwiNNatioN
|
- name: website
|
||||||
url: https://twinnation.org/health
|
url: https://twin.sh/health
|
||||||
interval: 1m
|
interval: 5m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
- name: GitHub
|
- "[BODY].status == UP"
|
||||||
|
|
||||||
|
- name: github
|
||||||
url: https://api.github.com/healthz
|
url: https://api.github.com/healthz
|
||||||
interval: 5m
|
interval: 5m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[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: 5m
|
interval: 5m
|
||||||
@@ -23,11 +30,14 @@ data:
|
|||||||
- "[BODY].text == pat(*cat*)"
|
- "[BODY].text == pat(*cat*)"
|
||||||
- "[STATUS] == pat(2*)"
|
- "[STATUS] == pat(2*)"
|
||||||
- "[CONNECTED] == true"
|
- "[CONNECTED] == true"
|
||||||
- name: Example
|
|
||||||
|
- name: example
|
||||||
url: https://example.com/
|
url: https://example.com/
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
kind: ConfigMap
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
metadata:
|
metadata:
|
||||||
name: gatus
|
name: gatus
|
||||||
namespace: kube-system
|
namespace: kube-system
|
||||||
@@ -41,14 +51,16 @@ spec:
|
|||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
k8s-app: gatus
|
app: gatus
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
|
||||||
k8s-app: gatus
|
|
||||||
name: gatus
|
name: gatus
|
||||||
namespace: kube-system
|
namespace: kube-system
|
||||||
|
labels:
|
||||||
|
app: gatus
|
||||||
spec:
|
spec:
|
||||||
|
serviceAccountName: gatus
|
||||||
|
terminationGracePeriodSeconds: 5
|
||||||
containers:
|
containers:
|
||||||
- image: twinproduction/gatus
|
- image: twinproduction/gatus
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
@@ -64,6 +76,22 @@ spec:
|
|||||||
requests:
|
requests:
|
||||||
cpu: 50m
|
cpu: 50m
|
||||||
memory: 30M
|
memory: 30M
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
successThreshold: 1
|
||||||
|
failureThreshold: 3
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
successThreshold: 1
|
||||||
|
failureThreshold: 5
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: /config
|
- mountPath: /config
|
||||||
name: gatus-config
|
name: gatus-config
|
||||||
@@ -84,4 +112,4 @@ spec:
|
|||||||
protocol: TCP
|
protocol: TCP
|
||||||
targetPort: 8080
|
targetPort: 8080
|
||||||
selector:
|
selector:
|
||||||
k8s-app: gatus
|
app: gatus
|
||||||
2
.gitattributes
vendored
@@ -1 +1 @@
|
|||||||
* text=lf
|
* text=auto eol=lf
|
||||||
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
|||||||
github: [TwinProduction]
|
github: [TwiN]
|
||||||
|
|||||||
BIN
.github/assets/dark-mode.png
vendored
|
Before Width: | Height: | Size: 38 KiB |
BIN
.github/assets/dashboard-conditions.png
vendored
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.github/assets/dashboard-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
BIN
.github/assets/example.png
vendored
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 43 KiB |
2
.github/assets/gatus-diagram.drawio
vendored
@@ -1 +1 @@
|
|||||||
<mxfile host="app.diagrams.net" modified="2021-07-30T04:27:17.723Z" agent="5.0 (Windows)" etag="hZKgW5ZLl0WUgYrCJXa4" version="14.9.2" type="device"><diagram id="1euQ5oT3BAcxlUCibzft" name="Page-1">7VvbcpswEP0aP6YDSPjy2MRum5lmptMkk+ZRARXUCtYV8oV+fUUsDLYSh07TLDP2k9FKWOKcw2q1EgNyka0/KjZPryDmchB48XpApoMg8IlHzE9lKTeWERlvDIkSsW3UGK7Fb26NnrUuRMyLnYYaQGox3zVGkOc80js2phSsdpt9B7nb65wl3DFcR0y61jsR63RjHYdeY//ERZLWPfuerclY3dgaipTFsGqZyGxALhSA3lxl6wsuK/BqXDb3fXimdjswxXPd5Yazebia3Z1ffuJfy+VwJm9K//ZsuPmXJZML+8B2sLqsEeCxAcQWQekUEsiZnDXWcwWLPOZVN54pNW0+A8yN0TfGH1zr0rLLFhqMKdWZtLWbPquOnn02aypgoSJ+4IFqjTCVcH2gXbhlwEiXQ8a1Ks19ikumxXJ3HMxqKNm2a2A2Fxbpv0Ddd1A3elNa5ImDfoNtBdQqFZpfz9kjBCvzxj2F49L8FV8fRtJ9cntDMLZyte+rX8t31ah/a0tbyh96/wksiqFIg5Yqv9n7Hwv3VeFdWBen63bltLSlV1Ry0FHJBFPJwfDEzkF2nnnb3oadeq7FYqch5L5d1x92yAj15XGmgRXTURoD/jSwDWPsNBCE2NMA8R1QjsPTkI5a3oRuaK7G0XKhQVXBNbaU9yMaMsaW8tjB6kilHXadRL2nGX6jaP1YY5yu9ExQ2Rmd2DnIDmqI409O7Bx2baiz9jY3daLnGXooalB1oucFelCTH5Sc6DlID0WN2+ixZg4704M699hRtlZBcwVLEXPl0PbCInF3RTn49yUjHfYtCT5ywLriRWGW1w9Cxeh4jfayRZNhN7iC/wXXxIHrWrLoZ++A8kNspOp91hZUX4ys1HShy/7BFaDD5eZsB8FQmm7PY7E0l0l1ecNZVtR200+rqn+YjtExdXOHV0xrrjIodO/wGo6w4aIOXDdc8kSxrHdgBT62uIJjTQ8F1lO9vG+AehQiONYEUXd+xqj8uBNetbGDv62zH6Pjb+sE7oqm+CUNDr1zyxTfLbt7YCLPeAaqf1EnGVNktAiyk9w9x+G9nZMko65OEtNHEnd1XnC1FBEvKiAqsxkF5Og+c/9UB35eg7rLz6koIuhhUqM+TIXmBag7Fd+shBTQO6gCGmJjhbxnj3byjXZN/uLunLihUgQGEpDyifQv9okhOkF3k8gHOdFWSbRrAEBRV7HUjQBuL9F1vD/d4+s4dKd7DGG/pkAnXQWKeg6nHmZboMUTrrZI2by6XGTyfWTW+QasSoIiMuiyBy6/QCEeQ1kyfQCtIWs1eC9FUlVo2JMyLLQUOb/YfqLlvZKfflne5HXUbYrNh1qPda3P3cjsDw==</diagram></mxfile>
|
<mxfile host="app.diagrams.net" modified="2021-09-12T22:49:28.336Z" agent="5.0 (Windows)" etag="r9FJ6Bphqwq-LaTO-jp3" version="15.0.6" type="device"><diagram id="FBbfVOMCjf6Z2LK8Yagy" name="Page-1">7Vtdb5swFP01edwEOCHJY5t03aR1q9RWbR9d8MCb4TJj8rFfP9OYBOI2YVrTi5S8RPjaxOack2sf7PTIJFlcSprFVxAy0fOccNEj057nDceu/iwDy1WADEarQCR5uAq5m8AN/8NM0DHRgocsbzRUAELxrBkMIE1ZoBoxKiXMm81+gGj2mtGIWYGbgAo7es9DFZuo5zibis+MR3HV9dgzNQmtWptAHtMQ5rUQueiRiQRQq6tkMWGiBK8CZnXfp1dq1yOTLFVtbvj2/UGwZFq4GpXZfXYJ8Yh9MN8yo6IwT2wGq5YVBCzUiJgiSBVDBCkVF5vouYQiDVnZjaNLmzZfATIddHXwJ1NqaeilhQIdilUiTO2qz7KjV5/NhHIoZMB2PJAZv6IyYmrXgw/XFGjtMkiYkkt9o2SCKj5rDoQaFUXrdhuc9YWB+h9g9yzYteKk4mlkwb8Bt0RqHnPFbjL6jMFc/+ReAnKmv4otdkNpP7m5YTQ2ejW/WM8xg5039L+KxTXp+86BwCIYktRoyeWDuf+58FgWPg6q4nRRr5wuTekNpey3lPIIU8n9Ezk7yfEwyRngkrPh47Fe1x1yiIPJjm9NAnOqgjgE/Elg6DYnAUKwJ4HhkeaZUds842NKeWRJOVcgy7U1tpK3lzN9H1vJYwurI5W2O2yrbdQ0XaXCEz+vNhyj8uOe+NnDD6pDcE/5bV9+e2UKeyd+jtVft+eHoPJzrBa7PT+oJttFdtnd54fg5jf/xM8efnB/P2aYNUOUSZjxkEmLuD1+sWkue//vHl1n2LW34a7tta9Ynmuv/cRliI8Y2Xp15A5H7RDzDoaY7bhvBA1+dQ8rj2BjVX1xDatrLS05LdSyg3g56Hi9tIHqC93techn+jIqL28ZTfIqrvupVXUQVB8dVM9OcVQpJhPIVfcAc/tjbMCIBdgtEyySNOkeXN4YXV/H6qi81ruWuKcjjtVRtecHd8//WN+It+aHoL4R9+yd5XI7Dn8zzrJT+Ltxnu0+899CA9G9ibM/agnW4SZO23vyNGEJyA6ag/7AwYZrjJsnm+dvnPfLk8RpmSdRDy0Q2+vmTM54wPISiDKsRwEpetrcPo2D/xKK2LZ3yvMAZNi9PLBGBi0PVIen6vZszgWH7oFFqvkQDyzk7Ui0Q4uk3zJpoq79q1HWtByAhgSEeOF9PfZpr8EQPVMeq5clrb1SH1XPtle6+4Ku4+0ZvwM6tn0ShrDfUqBtj9YS3FWq7bnu8hdSbR7TrLwsEnEWaLevwSolyAONLn1i4hpy/ryaJdMnUAqSWoMzwaOyQsGWlKFQgqdssv53nfM2+vbJfn2Td5W3vUeYQa4iWXqBri3SBtWRrQMs0nRx85fE57raHzvJxV8=</diagram></mxfile>
|
||||||
BIN
.github/assets/gatus-diagram.png
vendored
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
BIN
.github/assets/grafana-dashboard.png
vendored
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
.github/assets/logo-with-dark-text.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
.github/assets/logo-with-name.png
vendored
|
Before Width: | Height: | Size: 27 KiB |
BIN
.github/assets/logo.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
6
.github/codecov.yml
vendored
@@ -1,6 +1,12 @@
|
|||||||
ignore:
|
ignore:
|
||||||
- "watchdog/watchdog.go"
|
- "watchdog/watchdog.go"
|
||||||
|
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
status:
|
status:
|
||||||
patch: off
|
patch: off
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
target: 75%
|
||||||
|
threshold: null
|
||||||
|
|
||||||
|
|||||||
26
.github/workflows/benchmark.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: benchmark
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
repository:
|
||||||
|
description: "Repository to checkout. Useful for benchmarking a fork. Format should be <owner>/<repository>."
|
||||||
|
required: true
|
||||||
|
default: "TwiN/gatus"
|
||||||
|
ref:
|
||||||
|
description: "Branch, tag or SHA to checkout"
|
||||||
|
required: true
|
||||||
|
default: "master"
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: benchmark
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: 1.18
|
||||||
|
repository: "${{ github.event.inputs.repository }}"
|
||||||
|
ref: "${{ github.event.inputs.ref }}"
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Benchmark
|
||||||
|
run: go test -bench=. ./storage/store
|
||||||
16
.github/workflows/build.yml
vendored
@@ -10,24 +10,22 @@ on:
|
|||||||
- '*.md'
|
- '*.md'
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Go
|
- uses: actions/setup-go@v3
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
with:
|
||||||
go-version: 1.16
|
go-version: 1.18
|
||||||
- name: Check out code into the Go module directory
|
- uses: actions/checkout@v3
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Build binary to make sure it works
|
- name: Build binary to make sure it works
|
||||||
run: go build -mod vendor
|
run: go build -mod vendor
|
||||||
- name: Test
|
- name: Test
|
||||||
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
|
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
|
||||||
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
|
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
|
||||||
# was configured by the "Set up Go 1.15" step (otherwise, it'd use sudo's "go" executable)
|
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
|
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||||
- name: Codecov
|
- name: Codecov
|
||||||
uses: codecov/codecov-action@v1.5.2
|
uses: codecov/codecov-action@v2.1.0
|
||||||
with:
|
with:
|
||||||
file: ./coverage.txt
|
files: ./coverage.txt
|
||||||
|
|||||||
28
.github/workflows/publish-experimental.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: publish-experimental
|
||||||
|
on: [workflow_dispatch]
|
||||||
|
jobs:
|
||||||
|
publish-experimental:
|
||||||
|
name: publish-experimental
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Get image repository
|
||||||
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $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@v2
|
||||||
|
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
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_REPOSITORY }}:experimental
|
||||||
13
.github/workflows/publish-latest.yml
vendored
@@ -6,28 +6,27 @@ on:
|
|||||||
types: [completed]
|
types: [completed]
|
||||||
jobs:
|
jobs:
|
||||||
publish-latest:
|
publish-latest:
|
||||||
name: Publish latest
|
name: publish-latest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- uses: actions/checkout@v3
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Build and push docker image
|
- name: Build and push docker image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
|
|||||||
18
.github/workflows/publish-release.yml
vendored
@@ -4,14 +4,13 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
jobs:
|
jobs:
|
||||||
publish-release:
|
publish-release:
|
||||||
name: Publish release
|
name: publish-release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- uses: actions/checkout@v3
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Get the release
|
- name: Get the release
|
||||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
@@ -19,15 +18,14 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Build and push docker image
|
- name: Build and push docker image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }},${{ env.IMAGE_REPOSITORY }}:stable
|
||||||
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
|
||||||
|
|||||||
9
.gitignore
vendored
@@ -1,8 +1,7 @@
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
gatus
|
gatus
|
||||||
db.db
|
config/config.yml
|
||||||
config/config.yml
|
|
||||||
db.db-shm
|
|
||||||
db.db-wal
|
|
||||||
memory.db
|
|
||||||
@@ -13,7 +13,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
|
|||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /app/gatus .
|
COPY --from=builder /app/gatus .
|
||||||
COPY --from=builder /app/config.yaml ./config/config.yaml
|
COPY --from=builder /app/config.yaml ./config/config.yaml
|
||||||
COPY --from=builder /app/web/static ./web/static
|
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
|
|||||||
202
LICENSE
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
5
Makefile
@@ -1,5 +1,8 @@
|
|||||||
BINARY=gatus
|
BINARY=gatus
|
||||||
|
|
||||||
|
# Because there's a folder called "test", we need to make the target "test" phony
|
||||||
|
.PHONY: test
|
||||||
|
|
||||||
install:
|
install:
|
||||||
go build -mod vendor -o $(BINARY) .
|
go build -mod vendor -o $(BINARY) .
|
||||||
|
|
||||||
@@ -10,7 +13,7 @@ clean:
|
|||||||
rm $(BINARY)
|
rm $(BINARY)
|
||||||
|
|
||||||
test:
|
test:
|
||||||
sudo go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
|
go test ./... -cover
|
||||||
|
|
||||||
|
|
||||||
##########
|
##########
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
package alert
|
package alert
|
||||||
|
|
||||||
// Alert is the service's alert configuration
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
|
||||||
|
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alert is a core.Endpoint's alert configuration
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
// Type of alert (required)
|
// Type of alert (required)
|
||||||
Type Type `yaml:"type"`
|
Type Type `yaml:"type"`
|
||||||
|
|
||||||
// Enabled defines whether or not the alert is enabled
|
// Enabled defines whether the alert is enabled
|
||||||
//
|
//
|
||||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||||
// or not for provider.ParseWithDefaultAlert to work.
|
// or not for provider.ParseWithDefaultAlert to work.
|
||||||
Enabled *bool `yaml:"enabled"`
|
Enabled *bool `yaml:"enabled,omitempty"`
|
||||||
|
|
||||||
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
||||||
FailureThreshold int `yaml:"failure-threshold"`
|
FailureThreshold int `yaml:"failure-threshold"`
|
||||||
@@ -31,7 +41,7 @@ type Alert struct {
|
|||||||
|
|
||||||
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
||||||
// ongoing/triggered incidents
|
// ongoing/triggered incidents
|
||||||
ResolveKey string
|
ResolveKey string `yaml:"-"`
|
||||||
|
|
||||||
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
|
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
|
||||||
// should be set back to false. It is used to prevent the same alert from going out twice.
|
// should be set back to false. It is used to prevent the same alert from going out twice.
|
||||||
@@ -41,7 +51,21 @@ type Alert struct {
|
|||||||
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
|
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
|
||||||
// some reason, the alert provider always returns errors when trying to send the resolved notification
|
// some reason, the alert provider always returns errors when trying to send the resolved notification
|
||||||
// (SendOnResolved).
|
// (SendOnResolved).
|
||||||
Triggered bool
|
Triggered bool `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSetDefaults validates the alert's configuration and sets the default value of fields that have one
|
||||||
|
func (alert *Alert) ValidateAndSetDefaults() error {
|
||||||
|
if alert.FailureThreshold <= 0 {
|
||||||
|
alert.FailureThreshold = 3
|
||||||
|
}
|
||||||
|
if alert.SuccessThreshold <= 0 {
|
||||||
|
alert.SuccessThreshold = 2
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
|
||||||
|
return ErrAlertWithInvalidDescription
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDescription retrieves the description of the alert
|
// GetDescription retrieves the description of the alert
|
||||||
|
|||||||
@@ -1,6 +1,55 @@
|
|||||||
package alert
|
package alert
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||||
|
invalidDescription := "\""
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
alert Alert
|
||||||
|
expectedError error
|
||||||
|
expectedSuccessThreshold int
|
||||||
|
expectedFailureThreshold int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid-empty",
|
||||||
|
alert: Alert{
|
||||||
|
Description: nil,
|
||||||
|
FailureThreshold: 0,
|
||||||
|
SuccessThreshold: 0,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedFailureThreshold: 3,
|
||||||
|
expectedSuccessThreshold: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-description",
|
||||||
|
alert: Alert{
|
||||||
|
Description: &invalidDescription,
|
||||||
|
FailureThreshold: 10,
|
||||||
|
SuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
expectedError: ErrAlertWithInvalidDescription,
|
||||||
|
expectedFailureThreshold: 10,
|
||||||
|
expectedSuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
if err := scenario.alert.ValidateAndSetDefaults(); err != scenario.expectedError {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||||
|
}
|
||||||
|
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
|
||||||
|
t.Errorf("expected success threshold %v, got %v", scenario.expectedSuccessThreshold, scenario.alert.SuccessThreshold)
|
||||||
|
}
|
||||||
|
if scenario.alert.FailureThreshold != scenario.expectedFailureThreshold {
|
||||||
|
t.Errorf("expected failure threshold %v, got %v", scenario.expectedFailureThreshold, scenario.alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlert_IsEnabled(t *testing.T) {
|
func TestAlert_IsEnabled(t *testing.T) {
|
||||||
if (Alert{Enabled: nil}).IsEnabled() {
|
if (Alert{Enabled: nil}).IsEnabled() {
|
||||||
|
|||||||
@@ -11,12 +11,27 @@ const (
|
|||||||
// TypeDiscord is the Type for the discord alerting provider
|
// TypeDiscord is the Type for the discord alerting provider
|
||||||
TypeDiscord Type = "discord"
|
TypeDiscord Type = "discord"
|
||||||
|
|
||||||
|
// TypeEmail is the Type for the email alerting provider
|
||||||
|
TypeEmail Type = "email"
|
||||||
|
|
||||||
|
// TypeGoogleChat is the Type for the googlechat alerting provider
|
||||||
|
TypeGoogleChat Type = "googlechat"
|
||||||
|
|
||||||
|
// TypeMatrix is the Type for the matrix alerting provider
|
||||||
|
TypeMatrix Type = "matrix"
|
||||||
|
|
||||||
// TypeMattermost is the Type for the mattermost alerting provider
|
// TypeMattermost is the Type for the mattermost alerting provider
|
||||||
TypeMattermost Type = "mattermost"
|
TypeMattermost Type = "mattermost"
|
||||||
|
|
||||||
// TypeMessagebird is the Type for the messagebird alerting provider
|
// TypeMessagebird is the Type for the messagebird alerting provider
|
||||||
TypeMessagebird Type = "messagebird"
|
TypeMessagebird Type = "messagebird"
|
||||||
|
|
||||||
|
// TypeNtfy is the Type for the ntfy alerting provider
|
||||||
|
TypeNtfy Type = "ntfy"
|
||||||
|
|
||||||
|
// TypeOpsgenie is the Type for the opsgenie alerting provider
|
||||||
|
TypeOpsgenie Type = "opsgenie"
|
||||||
|
|
||||||
// TypePagerDuty is the Type for the pagerduty alerting provider
|
// TypePagerDuty is the Type for the pagerduty alerting provider
|
||||||
TypePagerDuty Type = "pagerduty"
|
TypePagerDuty Type = "pagerduty"
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,67 @@
|
|||||||
package alerting
|
package alerting
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider"
|
"github.com/TwiN/gatus/v4/alerting/provider"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/alerting/provider/custom"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/discord"
|
"github.com/TwiN/gatus/v4/alerting/provider/discord"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v4/alerting/provider/email"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v4/alerting/provider/googlechat"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
"github.com/TwiN/gatus/v4/alerting/provider/matrix"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/teams"
|
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
"github.com/TwiN/gatus/v4/alerting/provider/ntfy"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/provider/slack"
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/provider/teams"
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the configuration for alerting providers
|
// Config is the configuration for alerting providers
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Custom is the configuration for the custom alerting provider
|
// Custom is the configuration for the custom alerting provider
|
||||||
Custom *custom.AlertProvider `yaml:"custom"`
|
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||||
|
|
||||||
|
// googlechat is the configuration for the Google chat alerting provider
|
||||||
|
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
|
||||||
|
|
||||||
// Discord is the configuration for the discord alerting provider
|
// Discord is the configuration for the discord alerting provider
|
||||||
Discord *discord.AlertProvider `yaml:"discord"`
|
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
|
||||||
|
|
||||||
|
// Email is the configuration for the email alerting provider
|
||||||
|
Email *email.AlertProvider `yaml:"email,omitempty"`
|
||||||
|
|
||||||
|
// Matrix is the configuration for the matrix alerting provider
|
||||||
|
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
|
||||||
|
|
||||||
// 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,omitempty"`
|
||||||
|
|
||||||
// Messagebird is the configuration for the messagebird alerting provider
|
// Messagebird is the configuration for the messagebird alerting provider
|
||||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird"`
|
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
|
||||||
|
|
||||||
|
// Ntfy is the configuration for the ntfy alerting provider
|
||||||
|
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
||||||
|
|
||||||
|
// Opsgenie is the configuration for the opsgenie alerting provider
|
||||||
|
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
|
||||||
|
|
||||||
// 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,omitempty"`
|
||||||
|
|
||||||
// Slack is the configuration for the slack alerting provider
|
// Slack is the configuration for the slack alerting provider
|
||||||
Slack *slack.AlertProvider `yaml:"slack"`
|
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
||||||
|
|
||||||
// Teams is the configuration for the teams alerting provider
|
// Teams is the configuration for the teams alerting provider
|
||||||
Teams *teams.AlertProvider `yaml:"teams"`
|
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
|
||||||
|
|
||||||
// Telegram is the configuration for the telegram alerting provider
|
// Telegram is the configuration for the telegram alerting provider
|
||||||
Telegram *telegram.AlertProvider `yaml:"telegram"`
|
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
|
||||||
|
|
||||||
// Twilio is the configuration for the twilio alerting provider
|
// Twilio is the configuration for the twilio alerting provider
|
||||||
Twilio *twilio.AlertProvider `yaml:"twilio"`
|
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
||||||
@@ -59,6 +79,24 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return config.Discord
|
return config.Discord
|
||||||
|
case alert.TypeEmail:
|
||||||
|
if config.Email == nil {
|
||||||
|
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return config.Email
|
||||||
|
case alert.TypeGoogleChat:
|
||||||
|
if config.GoogleChat == nil {
|
||||||
|
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return config.GoogleChat
|
||||||
|
case alert.TypeMatrix:
|
||||||
|
if config.Matrix == nil {
|
||||||
|
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return config.Matrix
|
||||||
case alert.TypeMattermost:
|
case alert.TypeMattermost:
|
||||||
if config.Mattermost == nil {
|
if config.Mattermost == 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
|
||||||
@@ -71,6 +109,18 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return config.Messagebird
|
return config.Messagebird
|
||||||
|
case alert.TypeNtfy:
|
||||||
|
if config.Ntfy == nil {
|
||||||
|
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return config.Ntfy
|
||||||
|
case alert.TypeOpsgenie:
|
||||||
|
if config.Opsgenie == nil {
|
||||||
|
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return config.Opsgenie
|
||||||
case alert.TypePagerDuty:
|
case alert.TypePagerDuty:
|
||||||
if config.PagerDuty == nil {
|
if config.PagerDuty == 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
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ package custom
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/client"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
||||||
@@ -24,10 +22,10 @@ type AlertProvider struct {
|
|||||||
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
|
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
|
||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
ClientConfig *client.Config `yaml:"client"`
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
@@ -38,11 +36,6 @@ func (provider *AlertProvider) IsValid() bool {
|
|||||||
return len(provider.URL) > 0 && provider.ClientConfig != nil
|
return len(provider.URL) > 0 && provider.ClientConfig != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
|
||||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider {
|
|
||||||
return provider
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
||||||
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
|
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
|
||||||
status := "TRIGGERED"
|
status := "TRIGGERED"
|
||||||
@@ -57,69 +50,45 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription string, resolved bool) *http.Request {
|
func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
|
||||||
body := provider.Body
|
body, url, method := provider.Body, provider.URL, provider.Method
|
||||||
providerURL := provider.URL
|
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||||
method := provider.Method
|
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||||
|
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name)
|
||||||
if strings.Contains(body, "[ALERT_DESCRIPTION]") {
|
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name)
|
||||||
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription)
|
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group)
|
||||||
}
|
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group)
|
||||||
if strings.Contains(body, "[SERVICE_NAME]") {
|
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL)
|
||||||
body = strings.ReplaceAll(body, "[SERVICE_NAME]", serviceName)
|
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL)
|
||||||
}
|
if resolved {
|
||||||
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
|
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||||
if resolved {
|
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
} else {
|
||||||
} else {
|
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
||||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(providerURL, "[ALERT_DESCRIPTION]") {
|
|
||||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_DESCRIPTION]", alertDescription)
|
|
||||||
}
|
|
||||||
if strings.Contains(providerURL, "[SERVICE_NAME]") {
|
|
||||||
providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", serviceName)
|
|
||||||
}
|
|
||||||
if strings.Contains(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]") {
|
|
||||||
if resolved {
|
|
||||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
|
||||||
} else {
|
|
||||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(method) == 0 {
|
if len(method) == 0 {
|
||||||
method = http.MethodGet
|
method = http.MethodGet
|
||||||
}
|
}
|
||||||
bodyBuffer := bytes.NewBuffer([]byte(body))
|
bodyBuffer := bytes.NewBuffer([]byte(body))
|
||||||
request, _ := http.NewRequest(method, providerURL, bodyBuffer)
|
request, _ := http.NewRequest(method, url, bodyBuffer)
|
||||||
for k, v := range provider.Headers {
|
for k, v := range provider.Headers {
|
||||||
request.Header.Set(k, v)
|
request.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send a request to the alert provider and return the body
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
func (provider *AlertProvider) Send(serviceName, alertDescription string, resolved bool) ([]byte, error) {
|
request := provider.buildHTTPRequest(endpoint, alert, resolved)
|
||||||
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
|
|
||||||
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
|
|
||||||
return nil, errors.New("error")
|
|
||||||
}
|
|
||||||
return []byte("{}"), nil
|
|
||||||
}
|
|
||||||
request := provider.buildHTTPRequest(serviceName, alertDescription, resolved)
|
|
||||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, err := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
if err != nil {
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
return nil, fmt.Errorf("call to provider alert returned status code %d", response.StatusCode)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
|
||||||
}
|
}
|
||||||
return ioutil.ReadAll(response.Body)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
|||||||
@@ -1,106 +1,212 @@
|
|||||||
package custom
|
package custom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{URL: ""}
|
t.Run("invalid-provider", func(t *testing.T) {
|
||||||
if invalidProvider.IsValid() {
|
invalidProvider := AlertProvider{URL: ""}
|
||||||
t.Error("provider shouldn't have been valid")
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("valid-provider", func(t *testing.T) {
|
||||||
|
validProvider := AlertProvider{URL: "https://example.com"}
|
||||||
|
if validProvider.ClientConfig != nil {
|
||||||
|
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
||||||
|
}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
if validProvider.ClientConfig == nil {
|
||||||
|
t.Error("provider client config should have been set after IsValid() was executed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{URL: "http://example.com"}
|
for _, scenario := range scenarios {
|
||||||
if !validProvider.IsValid() {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
t.Error("provider should've been valid")
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
|
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
||||||
const (
|
|
||||||
ExpectedURL = "http://example.com/service-name?event=RESOLVED&description=alert-description"
|
|
||||||
ExpectedBody = "service-name,alert-description,RESOLVED"
|
|
||||||
)
|
|
||||||
customAlertProvider := &AlertProvider{
|
customAlertProvider := &AlertProvider{
|
||||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
|
||||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
Headers: nil,
|
|
||||||
}
|
}
|
||||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
|
alertDescription := "alert-description"
|
||||||
if request.URL.String() != ExpectedURL {
|
scenarios := []struct {
|
||||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
AlertProvider *AlertProvider
|
||||||
|
Resolved bool
|
||||||
|
ExpectedURL string
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
AlertProvider: customAlertProvider,
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AlertProvider: customAlertProvider,
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
body, _ := ioutil.ReadAll(request.Body)
|
for _, scenario := range scenarios {
|
||||||
if string(body) != ExpectedBody {
|
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||||
t.Error("expected body to be", ExpectedBody, "was", string(body))
|
request := customAlertProvider.buildHTTPRequest(
|
||||||
}
|
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
|
||||||
}
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
scenario.Resolved,
|
||||||
func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
|
)
|
||||||
const (
|
if request.URL.String() != scenario.ExpectedURL {
|
||||||
ExpectedURL = "http://example.com/service-name?event=TRIGGERED&description=alert-description"
|
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
|
||||||
ExpectedBody = "service-name,alert-description,TRIGGERED"
|
}
|
||||||
)
|
body, _ := io.ReadAll(request.Body)
|
||||||
customAlertProvider := &AlertProvider{
|
if string(body) != scenario.ExpectedBody {
|
||||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
|
||||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
}
|
||||||
Headers: map[string]string{"Authorization": "Basic hunter2"},
|
})
|
||||||
}
|
|
||||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", false)
|
|
||||||
if request.URL.String() != ExpectedURL {
|
|
||||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
|
||||||
}
|
|
||||||
body, _ := ioutil.ReadAll(request.Body)
|
|
||||||
if string(body) != ExpectedBody {
|
|
||||||
t.Error("expected body to be", ExpectedBody, "was", string(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProvider(t *testing.T) {
|
|
||||||
provider := AlertProvider{URL: "http://example.com"}
|
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
|
||||||
if customAlertProvider == nil {
|
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
|
||||||
}
|
|
||||||
if customAlertProvider.URL != "http://example.com" {
|
|
||||||
t.Error("expected URL to be http://example.com, got", customAlertProvider.URL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||||
const (
|
|
||||||
ExpectedURL = "http://example.com/service-name?event=test&description=alert-description"
|
|
||||||
ExpectedBody = "service-name,alert-description,test"
|
|
||||||
)
|
|
||||||
customAlertProvider := &AlertProvider{
|
customAlertProvider := &AlertProvider{
|
||||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
Headers: nil,
|
Headers: nil,
|
||||||
Placeholders: map[string]map[string]string{
|
Placeholders: map[string]map[string]string{
|
||||||
"ALERT_TRIGGERED_OR_RESOLVED": {
|
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||||
"RESOLVED": "test",
|
"RESOLVED": "fixed",
|
||||||
|
"TRIGGERED": "boom",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
|
alertDescription := "alert-description"
|
||||||
if request.URL.String() != ExpectedURL {
|
scenarios := []struct {
|
||||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
AlertProvider *AlertProvider
|
||||||
|
Resolved bool
|
||||||
|
ExpectedURL string
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
AlertProvider: customAlertProvider,
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AlertProvider: customAlertProvider,
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
body, _ := ioutil.ReadAll(request.Body)
|
for _, scenario := range scenarios {
|
||||||
if string(body) != ExpectedBody {
|
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||||
t.Error("expected body to be", ExpectedBody, "was", string(body))
|
request := customAlertProvider.buildHTTPRequest(
|
||||||
|
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if request.URL.String() != scenario.ExpectedURL {
|
||||||
|
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(request.Body)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||||
customAlertProvider := &AlertProvider{
|
customAlertProvider := &AlertProvider{
|
||||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
Headers: nil,
|
|
||||||
Placeholders: nil,
|
|
||||||
}
|
}
|
||||||
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
||||||
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
|
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
|
||||||
@@ -109,3 +215,12 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
|||||||
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
|
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +1,75 @@
|
|||||||
package discord
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return len(provider.WebhookURL) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
var message, results string
|
var message, results string
|
||||||
var colorCode int
|
var colorCode int
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||||
colorCode = 3066993
|
colorCode = 3066993
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
colorCode = 15158332
|
colorCode = 15158332
|
||||||
}
|
}
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
@@ -46,10 +85,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\\n> " + alertDescription
|
description = ":\\n> " + alertDescription
|
||||||
}
|
}
|
||||||
return &custom.AlertProvider{
|
return fmt.Sprintf(`{
|
||||||
URL: provider.WebhookURL,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: fmt.Sprintf(`{
|
|
||||||
"content": "",
|
"content": "",
|
||||||
"embeds": [
|
"embeds": [
|
||||||
{
|
{
|
||||||
@@ -65,9 +101,19 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`, message, description, colorCode, results),
|
}`, message, description, colorCode, results)
|
||||||
Headers: map[string]string{"Content-Type": "application/json"},
|
}
|
||||||
|
|
||||||
|
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||||
|
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.WebhookURL
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return provider.WebhookURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package discord
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -21,50 +22,237 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
alertDescription := "test"
|
Overrides: []Override{
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
{
|
||||||
if customAlertProvider == nil {
|
WebhookURL: "http://example.com",
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
if customAlertProvider.URL != "http://example.com" {
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
WebhookURL: "",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
if providerWithInvalidOverrideTo.IsValid() {
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
body := make(map[string]interface{})
|
providerWithValidOverride := AlertProvider{
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
WebhookURL: "http://example.com",
|
||||||
if err != nil {
|
Overrides: []Override{
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if expected := "An alert for **svc** has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["embeds"].([]interface{})[0].(map[string]interface{})["description"] {
|
if !providerWithValidOverride.IsValid() {
|
||||||
t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"])
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
defer client.InjectHTTPClient(nil)
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
firstDescription := "description-1"
|
||||||
if customAlertProvider == nil {
|
secondDescription := "description-2"
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
for _, scenario := range scenarios {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
}
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
if customAlertProvider.URL != "http://example.com" {
|
err := scenario.Provider.Send(
|
||||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
}
|
&scenario.Alert,
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
&core.Result{
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
ConditionResults: []*core.ConditionResult{
|
||||||
}
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
body := make(map[string]interface{})
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
},
|
||||||
if err != nil {
|
},
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"color\": 15158332,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"color\": 3066993,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
ExpectedOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example01.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.Name, func(t *testing.T) {
|
||||||
|
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||||
|
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
alerting/provider/email/email.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
gomail "gopkg.in/mail.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
||||||
|
type AlertProvider struct {
|
||||||
|
From string `yaml:"from"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
To string `yaml:"to"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
To string `yaml:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether the provider's configuration is valid
|
||||||
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
var username string
|
||||||
|
if len(provider.Username) > 0 {
|
||||||
|
username = provider.Username
|
||||||
|
} else {
|
||||||
|
username = provider.From
|
||||||
|
}
|
||||||
|
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
|
||||||
|
m := gomail.NewMessage()
|
||||||
|
m.SetHeader("From", provider.From)
|
||||||
|
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...)
|
||||||
|
m.SetHeader("Subject", subject)
|
||||||
|
m.SetBody("text/plain", body)
|
||||||
|
d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
|
||||||
|
return d.DialAndSend(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessageSubjectAndBody builds the message subject and body
|
||||||
|
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
|
||||||
|
var subject, message, results string
|
||||||
|
if resolved {
|
||||||
|
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = "\n\nAlert description: " + alertDescription
|
||||||
|
}
|
||||||
|
return subject, message + description + "\n\nCondition results:\n" + results
|
||||||
|
}
|
||||||
|
|
||||||
|
// getToForGroup returns the appropriate email integration to for a given group
|
||||||
|
func (provider *AlertProvider) getToForGroup(group string) string {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.To
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
183
alerting/provider/email/email_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{}
|
||||||
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
To: "to@example.com",
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
To: "",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideTo.IsValid() {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
From: "from@example.com",
|
||||||
|
Password: "password",
|
||||||
|
Host: "smtp.gmail.com",
|
||||||
|
Port: 587,
|
||||||
|
To: "to@example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
To: "to@example.com",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !providerWithValidOverride.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedSubject string
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if subject != scenario.ExpectedSubject {
|
||||||
|
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
|
||||||
|
}
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_getToForGroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
ExpectedOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
To: "to@example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "to@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
To: "to@example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "to@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
To: "to@example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
To: "to01@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "to@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
To: "to@example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
To: "to01@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "to01@example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.Name, func(t *testing.T) {
|
||||||
|
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||||
|
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
156
alerting/provider/googlechat/googlechat.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package googlechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Google chat
|
||||||
|
type AlertProvider struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
|
||||||
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether the provider's configuration is valid
|
||||||
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
if provider.ClientConfig == nil {
|
||||||
|
provider.ClientConfig = client.GetDefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(provider.WebhookURL) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
|
var message, color string
|
||||||
|
if resolved {
|
||||||
|
color = "#36A64F"
|
||||||
|
message = fmt.Sprintf("<font color='%s'>An alert has been resolved after passing successfully %d time(s) in a row</font>", color, alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
color = "#DD0000"
|
||||||
|
message = fmt.Sprintf("<font color='%s'>An alert has been triggered due to having failed %d time(s) in a row</font>", color, alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
var results string
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
results += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = ":: " + alertDescription
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`{
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"keyValue": {
|
||||||
|
"topLabel": "%s [%s]",
|
||||||
|
"content": "%s",
|
||||||
|
"contentMultiline": "true",
|
||||||
|
"bottomLabel": "%s",
|
||||||
|
"icon": "BOOKMARK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyValue": {
|
||||||
|
"topLabel": "Condition results",
|
||||||
|
"content": "%s",
|
||||||
|
"contentMultiline": "true",
|
||||||
|
"icon": "DESCRIPTION"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"textButton": {
|
||||||
|
"text": "URL",
|
||||||
|
"onClick": {
|
||||||
|
"openLink": {
|
||||||
|
"url": "%s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`, endpoint.Name, endpoint.Group, message, description, results, endpoint.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||||
|
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.WebhookURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.WebhookURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
260
alerting/provider/googlechat/googlechat_test.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package googlechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||||
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
WebhookURL: "",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideTo.IsValid() {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !providerWithValidOverride.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-1\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n \"cards\": [\n {\n \"sections\": [\n {\n \"widgets\": [\n {\n \"keyValue\": {\n \"topLabel\": \"endpoint-name []\",\n \"content\": \"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e\",\n \"contentMultiline\": \"true\",\n \"bottomLabel\": \":: description-2\",\n \"icon\": \"BOOKMARK\"\n }\n },\n {\n \"keyValue\": {\n \"topLabel\": \"Condition results\",\n \"content\": \"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e\",\n \"contentMultiline\": \"true\",\n \"icon\": \"DESCRIPTION\"\n }\n },\n {\n \"buttons\": [\n {\n \"textButton\": {\n \"text\": \"URL\",\n \"onClick\": {\n \"openLink\": {\n \"url\": \"\"\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n]\n}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
e, _ := json.Marshal(scenario.ExpectedBody)
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected %s, got %s", e, b)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
ExpectedOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example01.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.Name, func(t *testing.T) {
|
||||||
|
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||||
|
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
183
alerting/provider/matrix/matrix.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Matrix
|
||||||
|
type AlertProvider struct {
|
||||||
|
MatrixProviderConfig `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
|
||||||
|
MatrixProviderConfig `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultHomeserverURL = "https://matrix-client.matrix.org"
|
||||||
|
|
||||||
|
type MatrixProviderConfig struct {
|
||||||
|
// ServerURL is the custom homeserver to use (optional)
|
||||||
|
ServerURL string `yaml:"server-url"`
|
||||||
|
|
||||||
|
// AccessToken is the bot user's access token to send messages
|
||||||
|
AccessToken string `yaml:"access-token"`
|
||||||
|
|
||||||
|
// InternalRoomID is the room that the bot user has permissions to send messages to
|
||||||
|
InternalRoomID string `yaml:"internal-room-id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether the provider's configuration is valid
|
||||||
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
|
config := provider.getConfigForGroup(endpoint.Group)
|
||||||
|
if config.ServerURL == "" {
|
||||||
|
config.ServerURL = defaultHomeserverURL
|
||||||
|
}
|
||||||
|
// The Matrix endpoint requires a unique transaction ID for each event sent
|
||||||
|
txnId := randStringBytes(24)
|
||||||
|
request, err := http.NewRequest(
|
||||||
|
http.MethodPut,
|
||||||
|
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
|
||||||
|
config.ServerURL,
|
||||||
|
url.PathEscape(config.InternalRoomID),
|
||||||
|
txnId,
|
||||||
|
url.QueryEscape(config.AccessToken),
|
||||||
|
),
|
||||||
|
buffer,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
|
return fmt.Sprintf(`{
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
"body": "%s",
|
||||||
|
"formatted_body": "%s"
|
||||||
|
}`,
|
||||||
|
buildPlaintextMessageBody(endpoint, alert, result, resolved),
|
||||||
|
buildHTMLMessageBody(endpoint, alert, result, resolved),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPlaintextMessageBody builds the message body in plaintext to include in request
|
||||||
|
func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
|
var message, results string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✓"
|
||||||
|
} else {
|
||||||
|
prefix = "✕"
|
||||||
|
}
|
||||||
|
results += fmt.Sprintf("\\n%s - %s", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = "\\n" + alertDescription
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%s\\n%s", message, description, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHTMLMessageBody builds the message body in HTML to include in request
|
||||||
|
func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
|
var message, results string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
results += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = fmt.Sprintf("\\n<blockquote>%s</blockquote>", alertDescription)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("<h3>%s</h3>%s\\n<h5>Condition results</h5><ul>%s</ul>", message, description, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getConfigForGroup returns the appropriate configuration for a given group
|
||||||
|
func (provider *AlertProvider) getConfigForGroup(group string) MatrixProviderConfig {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.MatrixProviderConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.MatrixProviderConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func randStringBytes(n int) string {
|
||||||
|
// All the compatible characters to use in a transaction ID
|
||||||
|
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
b := make([]byte, n)
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
for i := range b {
|
||||||
|
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
331
alerting/provider/matrix/matrix_test.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
AccessToken: "",
|
||||||
|
InternalRoomID: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
validProviderWithHomeserver := AlertProvider{
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !validProviderWithHomeserver.IsValid() {
|
||||||
|
t.Error("provider with homeserver should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "",
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
AccessToken: "",
|
||||||
|
InternalRoomID: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
AccessToken: "",
|
||||||
|
InternalRoomID: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideTo.IsValid() {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !providerWithValidOverride.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n\t\"msgtype\": \"m.text\",\n\t\"format\": \"org.matrix.custom.html\",\n\t\"body\": \"An alert for `endpoint-name` has been triggered due to having failed 3 time(s) in a row\\ndescription-1\\n\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\n\t\"formatted_body\": \"<h3>An alert for <code>endpoint-name</code> has been triggered due to having failed 3 time(s) in a row</h3>\\n<blockquote>description-1</blockquote>\\n<h5>Condition results</h5><ul><li>❌ - <code>[CONNECTED] == true</code></li><li>❌ - <code>[STATUS] == 200</code></li></ul>\"\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n\t\"msgtype\": \"m.text\",\n\t\"format\": \"org.matrix.custom.html\",\n\t\"body\": \"An alert for `endpoint-name` has been resolved after passing successfully 5 time(s) in a row\\ndescription-2\\n\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\n\t\"formatted_body\": \"<h3>An alert for <code>endpoint-name</code> has been resolved after passing successfully 5 time(s) in a row</h3>\\n<blockquote>description-2</blockquote>\\n<h5>Condition results</h5><ul><li>✅ - <code>[CONNECTED] == true</code></li><li>✅ - <code>[STATUS] == 200</code></li></ul>\"\n}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
ExpectedOutput MatrixProviderConfig
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example01.com",
|
||||||
|
AccessToken: "12",
|
||||||
|
InternalRoomID: "!a:example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
MatrixProviderConfig: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example01.com",
|
||||||
|
AccessToken: "12",
|
||||||
|
InternalRoomID: "!a:example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: MatrixProviderConfig{
|
||||||
|
ServerURL: "https://example01.com",
|
||||||
|
AccessToken: "12",
|
||||||
|
InternalRoomID: "!a:example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.Name, func(t *testing.T) {
|
||||||
|
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||||
|
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
package mattermost
|
package mattermost
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
"github.com/TwinProduction/gatus/client"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
"github.com/TwinProduction/gatus/core"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
||||||
@@ -15,10 +16,19 @@ type AlertProvider struct {
|
|||||||
WebhookURL string `yaml:"webhook-url"`
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
ClientConfig *client.Config `yaml:"client"`
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
@@ -26,18 +36,45 @@ func (provider *AlertProvider) IsValid() bool {
|
|||||||
if provider.ClientConfig == nil {
|
if provider.ClientConfig == nil {
|
||||||
provider.ClientConfig = client.GetDefaultConfig()
|
provider.ClientConfig = client.GetDefaultConfig()
|
||||||
}
|
}
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return len(provider.WebhookURL) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
var message string
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
var color string
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
|
var message, color string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||||
color = "#36A64F"
|
color = "#36A64F"
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
}
|
}
|
||||||
var results string
|
var results string
|
||||||
@@ -54,14 +91,10 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\\n> " + alertDescription
|
description = ":\\n> " + alertDescription
|
||||||
}
|
}
|
||||||
return &custom.AlertProvider{
|
return fmt.Sprintf(`{
|
||||||
URL: provider.WebhookURL,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
ClientConfig: provider.ClientConfig,
|
|
||||||
Body: fmt.Sprintf(`{
|
|
||||||
"text": "",
|
"text": "",
|
||||||
"username": "gatus",
|
"username": "gatus",
|
||||||
"icon_url": "https://raw.githubusercontent.com/TwinProduction/gatus/master/static/logo.png",
|
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||||
"attachments": [
|
"attachments": [
|
||||||
{
|
{
|
||||||
"title": ":rescue_worker_helmet: Gatus",
|
"title": ":rescue_worker_helmet: Gatus",
|
||||||
@@ -83,9 +116,19 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`, message, message, description, color, service.URL, results),
|
}`, message, message, description, color, endpoint.URL, results)
|
||||||
Headers: map[string]string{"Content-Type": "application/json"},
|
}
|
||||||
|
|
||||||
|
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||||
|
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.WebhookURL
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return provider.WebhookURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package mattermost
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -21,50 +22,241 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
alertDescription := "test"
|
Overrides: []Override{
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
{
|
||||||
if customAlertProvider == nil {
|
WebhookURL: "http://example.com",
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
|
||||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
if customAlertProvider.URL != "http://example.org" {
|
|
||||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
providerWithInvalidOverrideWebHookUrl := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
|
||||||
|
WebhookURL: "",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
if providerWithInvalidOverrideWebHookUrl.IsValid() {
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
t.Error("provider WebHookURL shoudn't have been valid")
|
||||||
}
|
}
|
||||||
body := make(map[string]interface{})
|
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
providerWithValidOverride := AlertProvider{
|
||||||
if err != nil {
|
WebhookURL: "http://example.com",
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
|
if !providerWithValidOverride.IsValid() {
|
||||||
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
defer client.InjectHTTPClient(nil)
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
firstDescription := "description-1"
|
||||||
if customAlertProvider == nil {
|
secondDescription := "description-2"
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
for _, scenario := range scenarios {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
}
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
if customAlertProvider.URL != "http://example.org" {
|
err := scenario.Provider.Send(
|
||||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
}
|
&scenario.Alert,
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
&core.Result{
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
ConditionResults: []*core.ConditionResult{
|
||||||
}
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
body := make(map[string]interface{})
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
},
|
||||||
if err != nil {
|
},
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
ExpectedOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example01.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.Name, func(t *testing.T) {
|
||||||
|
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||||
|
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package messagebird
|
package messagebird
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -19,8 +21,8 @@ type AlertProvider struct {
|
|||||||
Originator string `yaml:"originator"`
|
Originator string `yaml:"originator"`
|
||||||
Recipients string `yaml:"recipients"`
|
Recipients string `yaml:"recipients"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
@@ -28,29 +30,40 @@ func (provider *AlertProvider) IsValid() bool {
|
|||||||
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
|
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
// Send an alert using the provider
|
||||||
// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
||||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey))
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf(`{
|
||||||
return &custom.AlertProvider{
|
|
||||||
URL: restAPIURL,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: fmt.Sprintf(`{
|
|
||||||
"originator": "%s",
|
"originator": "%s",
|
||||||
"recipients": "%s",
|
"recipients": "%s",
|
||||||
"body": "%s"
|
"body": "%s"
|
||||||
}`, provider.Originator, provider.Recipients, message),
|
}`, provider.Originator, provider.Recipients, message)
|
||||||
Headers: map[string]string{
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": fmt.Sprintf("AccessKey %s", provider.AccessKey),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package messagebird
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -25,54 +26,137 @@ func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
provider := AlertProvider{
|
defer client.InjectHTTPClient(nil)
|
||||||
AccessKey: "1",
|
firstDescription := "description-1"
|
||||||
Originator: "1",
|
secondDescription := "description-2"
|
||||||
Recipients: "1",
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
for _, scenario := range scenarios {
|
||||||
if customAlertProvider == nil {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
}
|
err := scenario.Provider.Send(
|
||||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
&scenario.Alert,
|
||||||
}
|
&core.Result{
|
||||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
ConditionResults: []*core.ConditionResult{
|
||||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
}
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
},
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
},
|
||||||
}
|
scenario.Resolved,
|
||||||
body := make(map[string]interface{})
|
)
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
if scenario.ExpectedError && err == nil {
|
||||||
if err != nil {
|
t.Error("expected error, got none")
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
provider := AlertProvider{
|
firstDescription := "description-1"
|
||||||
AccessKey: "1",
|
secondDescription := "description-2"
|
||||||
Originator: "1",
|
scenarios := []struct {
|
||||||
Recipients: "1",
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n \"originator\": \"2\",\n \"recipients\": \"3\",\n \"body\": \"TRIGGERED: endpoint-name - description-1\"\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n \"originator\": \"5\",\n \"recipients\": \"6\",\n \"body\": \"RESOLVED: endpoint-name - description-2\"\n}",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
|
for _, scenario := range scenarios {
|
||||||
if customAlertProvider == nil {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
body := scenario.Provider.buildRequestBody(
|
||||||
}
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
&scenario.Alert,
|
||||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
&core.Result{
|
||||||
}
|
ConditionResults: []*core.ConditionResult{
|
||||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
}
|
},
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
},
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
scenario.Resolved,
|
||||||
}
|
)
|
||||||
body := make(map[string]interface{})
|
if body != scenario.ExpectedBody {
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
if err != nil {
|
}
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
alerting/provider/ntfy/ntfy.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultURL = "https://ntfy.sh"
|
||||||
|
DefaultPriority = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||||
|
type AlertProvider struct {
|
||||||
|
Topic string `yaml:"topic"`
|
||||||
|
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
|
||||||
|
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether the provider's configuration is valid
|
||||||
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
if len(provider.URL) == 0 {
|
||||||
|
provider.URL = DefaultURL
|
||||||
|
}
|
||||||
|
if provider.Priority == 0 {
|
||||||
|
provider.Priority = DefaultPriority
|
||||||
|
}
|
||||||
|
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
|
var message, tag string
|
||||||
|
if len(alert.GetDescription()) > 0 {
|
||||||
|
message = endpoint.DisplayName() + " - " + alert.GetDescription()
|
||||||
|
} else {
|
||||||
|
message = endpoint.DisplayName()
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
tag = "white_check_mark"
|
||||||
|
} else {
|
||||||
|
tag = "x"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`{
|
||||||
|
"topic": "%s",
|
||||||
|
"title": "Gatus",
|
||||||
|
"message": "%s",
|
||||||
|
"tags": ["%s"],
|
||||||
|
"priority": %d
|
||||||
|
}`, provider.Topic, message, tag, provider.Priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
104
alerting/provider/ntfy/ntfy_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-url-should-use-default-value",
|
||||||
|
provider: AlertProvider{Topic: "example", Priority: 1},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-topic",
|
||||||
|
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-priority-too-high",
|
||||||
|
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-priority-too-low",
|
||||||
|
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-priority-should-use-default-value",
|
||||||
|
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
if scenario.provider.IsValid() != scenario.expected {
|
||||||
|
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n \"topic\": \"example\",\n \"title\": \"Gatus\",\n \"message\": \"endpoint-name - description-1\",\n \"tags\": [\"x\"],\n \"priority\": 1\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n \"topic\": \"example\",\n \"title\": \"Gatus\",\n \"message\": \"endpoint-name - description-2\",\n \"tags\": [\"white_check_mark\"],\n \"priority\": 2\n}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
240
alerting/provider/opsgenie/opsgenie.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package opsgenie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
restAPI = "https://api.opsgenie.com/v2/alerts"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertProvider struct {
|
||||||
|
// APIKey to use for
|
||||||
|
APIKey string `yaml:"api-key"`
|
||||||
|
|
||||||
|
// Priority to be used in Opsgenie alert payload
|
||||||
|
//
|
||||||
|
// default: P1
|
||||||
|
Priority string `yaml:"priority"`
|
||||||
|
|
||||||
|
// Source define source to be used in Opsgenie alert payload
|
||||||
|
//
|
||||||
|
// default: gatus
|
||||||
|
Source string `yaml:"source"`
|
||||||
|
|
||||||
|
// EntityPrefix is a prefix to be used in entity argument in Opsgenie alert payload
|
||||||
|
//
|
||||||
|
// default: gatus-
|
||||||
|
EntityPrefix string `yaml:"entity-prefix"`
|
||||||
|
|
||||||
|
//AliasPrefix is a prefix to be used in alias argument in Opsgenie alert payload
|
||||||
|
//
|
||||||
|
// default: gatus-healthcheck-
|
||||||
|
AliasPrefix string `yaml:"alias-prefix"`
|
||||||
|
|
||||||
|
// Tags to be used in Opsgenie alert payload
|
||||||
|
//
|
||||||
|
// default: []
|
||||||
|
Tags []string `yaml:"tags"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether the provider's configuration is valid
|
||||||
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
return len(provider.APIKey) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
//
|
||||||
|
// Relevant: https://docs.opsgenie.com/docs/alert-api
|
||||||
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
err := provider.createAlert(endpoint, alert, result, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
err = provider.closeAlert(endpoint, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alert.IsSendingOnResolved() {
|
||||||
|
if resolved {
|
||||||
|
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
|
||||||
|
alert.ResolveKey = ""
|
||||||
|
} else {
|
||||||
|
alert.ResolveKey = provider.alias(buildKey(endpoint))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved)
|
||||||
|
_, err := provider.sendRequest(restAPI, http.MethodPost, payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error {
|
||||||
|
payload := provider.buildCloseRequestBody(endpoint, alert)
|
||||||
|
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias"
|
||||||
|
_, err := provider.sendRequest(url, http.MethodPost, payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) (*http.Response, error) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fail to build alert payload: %v", payload)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
|
||||||
|
res, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.StatusCode > 399 {
|
||||||
|
rBody, _ := io.ReadAll(res.Body)
|
||||||
|
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", res.StatusCode, string(rBody))
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest {
|
||||||
|
var message, description, results string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||||
|
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription())
|
||||||
|
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
if endpoint.Group != "" {
|
||||||
|
message = fmt.Sprintf("[%s] %s", endpoint.Group, message)
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "▣"
|
||||||
|
} else {
|
||||||
|
prefix = "▢"
|
||||||
|
}
|
||||||
|
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
description = description + "\n" + results
|
||||||
|
key := buildKey(endpoint)
|
||||||
|
details := map[string]string{
|
||||||
|
"endpoint:url": endpoint.URL,
|
||||||
|
"endpoint:group": endpoint.Group,
|
||||||
|
"result:hostname": result.Hostname,
|
||||||
|
"result:ip": result.IP,
|
||||||
|
"result:dns_code": result.DNSRCode,
|
||||||
|
"result:errors": strings.Join(result.Errors, ","),
|
||||||
|
}
|
||||||
|
for k, v := range details {
|
||||||
|
if v == "" {
|
||||||
|
delete(details, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.HTTPStatus > 0 {
|
||||||
|
details["result:http_status"] = strconv.Itoa(result.HTTPStatus)
|
||||||
|
}
|
||||||
|
return alertCreateRequest{
|
||||||
|
Message: message,
|
||||||
|
Description: description,
|
||||||
|
Source: provider.source(),
|
||||||
|
Priority: provider.priority(),
|
||||||
|
Alias: provider.alias(key),
|
||||||
|
Entity: provider.entity(key),
|
||||||
|
Tags: provider.Tags,
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) alertCloseRequest {
|
||||||
|
return alertCloseRequest{
|
||||||
|
Source: buildKey(endpoint),
|
||||||
|
Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) source() string {
|
||||||
|
source := provider.Source
|
||||||
|
if source == "" {
|
||||||
|
return "gatus"
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) alias(key string) string {
|
||||||
|
alias := provider.AliasPrefix
|
||||||
|
if alias == "" {
|
||||||
|
alias = "gatus-healthcheck-"
|
||||||
|
}
|
||||||
|
return alias + key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) entity(key string) string {
|
||||||
|
alias := provider.EntityPrefix
|
||||||
|
if alias == "" {
|
||||||
|
alias = "gatus-"
|
||||||
|
}
|
||||||
|
return alias + key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) priority() string {
|
||||||
|
priority := provider.Priority
|
||||||
|
if priority == "" {
|
||||||
|
return "P1"
|
||||||
|
}
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKey(endpoint *core.Endpoint) string {
|
||||||
|
name := toKebabCase(endpoint.Name)
|
||||||
|
if endpoint.Group == "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return toKebabCase(endpoint.Group) + "-" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
func toKebabCase(val string) string {
|
||||||
|
return strings.ToLower(strings.ReplaceAll(val, " ", "-"))
|
||||||
|
}
|
||||||
|
|
||||||
|
type alertCreateRequest struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Entity string `json:"entity"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Details map[string]string `json:"details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type alertCloseRequest struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
319
alerting/provider/opsgenie/opsgenie_test.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package opsgenie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{APIKey: ""}
|
||||||
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
description := "my bad alert description"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedError: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedError: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedError: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedError: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
description := "alert description"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider *AlertProvider
|
||||||
|
Alert *alert.Alert
|
||||||
|
Endpoint *core.Endpoint
|
||||||
|
Result *core.Result
|
||||||
|
Resolved bool
|
||||||
|
want alertCreateRequest
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "missing all params (unresolved)",
|
||||||
|
Provider: &AlertProvider{},
|
||||||
|
Alert: &alert.Alert{},
|
||||||
|
Endpoint: &core.Endpoint{},
|
||||||
|
Result: &core.Result{},
|
||||||
|
Resolved: false,
|
||||||
|
want: alertCreateRequest{
|
||||||
|
Message: " - ",
|
||||||
|
Priority: "P1",
|
||||||
|
Source: "gatus",
|
||||||
|
Entity: "gatus-",
|
||||||
|
Alias: "gatus-healthcheck-",
|
||||||
|
Description: "An alert for ** has been triggered due to having failed 0 time(s) in a row\n",
|
||||||
|
Tags: nil,
|
||||||
|
Details: map[string]string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing all params (resolved)",
|
||||||
|
Provider: &AlertProvider{},
|
||||||
|
Alert: &alert.Alert{},
|
||||||
|
Endpoint: &core.Endpoint{},
|
||||||
|
Result: &core.Result{},
|
||||||
|
Resolved: true,
|
||||||
|
want: alertCreateRequest{
|
||||||
|
Message: "RESOLVED: - ",
|
||||||
|
Priority: "P1",
|
||||||
|
Source: "gatus",
|
||||||
|
Entity: "gatus-",
|
||||||
|
Alias: "gatus-healthcheck-",
|
||||||
|
Description: "An alert for ** has been resolved after passing successfully 0 time(s) in a row\n",
|
||||||
|
Tags: nil,
|
||||||
|
Details: map[string]string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "with default options (unresolved)",
|
||||||
|
Provider: &AlertProvider{},
|
||||||
|
Alert: &alert.Alert{
|
||||||
|
Description: &description,
|
||||||
|
FailureThreshold: 3,
|
||||||
|
},
|
||||||
|
Endpoint: &core.Endpoint{
|
||||||
|
Name: "my super app",
|
||||||
|
},
|
||||||
|
Result: &core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{
|
||||||
|
Condition: "[STATUS] == 200",
|
||||||
|
Success: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: "[BODY] == OK",
|
||||||
|
Success: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Resolved: false,
|
||||||
|
want: alertCreateRequest{
|
||||||
|
Message: "my super app - " + description,
|
||||||
|
Priority: "P1",
|
||||||
|
Source: "gatus",
|
||||||
|
Entity: "gatus-my-super-app",
|
||||||
|
Alias: "gatus-healthcheck-my-super-app",
|
||||||
|
Description: "An alert for *my super app* has been triggered due to having failed 3 time(s) in a row\n▣ - `[STATUS] == 200`\n▢ - `[BODY] == OK`\n",
|
||||||
|
Tags: nil,
|
||||||
|
Details: map[string]string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "with custom options (resolved)",
|
||||||
|
Provider: &AlertProvider{
|
||||||
|
Priority: "P5",
|
||||||
|
EntityPrefix: "oompa-",
|
||||||
|
AliasPrefix: "loompa-",
|
||||||
|
Source: "gatus-hc",
|
||||||
|
Tags: []string{"do-ba-dee-doo"},
|
||||||
|
},
|
||||||
|
Alert: &alert.Alert{
|
||||||
|
Description: &description,
|
||||||
|
SuccessThreshold: 4,
|
||||||
|
},
|
||||||
|
Endpoint: &core.Endpoint{
|
||||||
|
Name: "my mega app",
|
||||||
|
},
|
||||||
|
Result: &core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{
|
||||||
|
Condition: "[STATUS] == 200",
|
||||||
|
Success: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Resolved: true,
|
||||||
|
want: alertCreateRequest{
|
||||||
|
Message: "RESOLVED: my mega app - " + description,
|
||||||
|
Priority: "P5",
|
||||||
|
Source: "gatus-hc",
|
||||||
|
Entity: "oompa-my-mega-app",
|
||||||
|
Alias: "loompa-my-mega-app",
|
||||||
|
Description: "An alert for *my mega app* has been resolved after passing successfully 4 time(s) in a row\n▣ - `[STATUS] == 200`\n",
|
||||||
|
Tags: []string{"do-ba-dee-doo"},
|
||||||
|
Details: map[string]string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "with default options and details (unresolved)",
|
||||||
|
Provider: &AlertProvider{
|
||||||
|
Tags: []string{"foo"},
|
||||||
|
},
|
||||||
|
Alert: &alert.Alert{
|
||||||
|
Description: &description,
|
||||||
|
FailureThreshold: 6,
|
||||||
|
},
|
||||||
|
Endpoint: &core.Endpoint{
|
||||||
|
Name: "my app",
|
||||||
|
Group: "end game",
|
||||||
|
URL: "https://my.go/app",
|
||||||
|
},
|
||||||
|
Result: &core.Result{
|
||||||
|
HTTPStatus: 400,
|
||||||
|
Hostname: "my.go",
|
||||||
|
Errors: []string{"error 01", "error 02"},
|
||||||
|
Success: false,
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{
|
||||||
|
Condition: "[STATUS] == 200",
|
||||||
|
Success: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Resolved: false,
|
||||||
|
want: alertCreateRequest{
|
||||||
|
Message: "[end game] my app - " + description,
|
||||||
|
Priority: "P1",
|
||||||
|
Source: "gatus",
|
||||||
|
Entity: "gatus-end-game-my-app",
|
||||||
|
Alias: "gatus-healthcheck-end-game-my-app",
|
||||||
|
Description: "An alert for *end game/my app* has been triggered due to having failed 6 time(s) in a row\n▢ - `[STATUS] == 200`\n",
|
||||||
|
Tags: []string{"foo"},
|
||||||
|
Details: map[string]string{
|
||||||
|
"endpoint:url": "https://my.go/app",
|
||||||
|
"endpoint:group": "end game",
|
||||||
|
"result:hostname": "my.go",
|
||||||
|
"result:errors": "error 01,error 02",
|
||||||
|
"result:http_status": "400",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
actual := scenario
|
||||||
|
t.Run(actual.Name, func(t *testing.T) {
|
||||||
|
if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
|
||||||
|
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
description := "alert description"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider *AlertProvider
|
||||||
|
Alert *alert.Alert
|
||||||
|
Endpoint *core.Endpoint
|
||||||
|
want alertCloseRequest
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Missing all values",
|
||||||
|
Provider: &AlertProvider{},
|
||||||
|
Alert: &alert.Alert{},
|
||||||
|
Endpoint: &core.Endpoint{},
|
||||||
|
want: alertCloseRequest{
|
||||||
|
Source: "",
|
||||||
|
Note: "RESOLVED: - ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Basic values",
|
||||||
|
Provider: &AlertProvider{},
|
||||||
|
Alert: &alert.Alert{
|
||||||
|
Description: &description,
|
||||||
|
},
|
||||||
|
Endpoint: &core.Endpoint{
|
||||||
|
Name: "endpoint name",
|
||||||
|
},
|
||||||
|
want: alertCloseRequest{
|
||||||
|
Source: "endpoint-name",
|
||||||
|
Note: "RESOLVED: endpoint name - alert description",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
actual := scenario
|
||||||
|
t.Run(actual.Name, func(t *testing.T) {
|
||||||
|
if got := actual.Provider.buildCloseRequestBody(actual.Endpoint, actual.Alert); !reflect.DeepEqual(got, actual.want) {
|
||||||
|
t.Errorf("buildCloseRequestBody() = %v, want %v", got, actual.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package pagerduty
|
package pagerduty
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -17,33 +21,84 @@ const (
|
|||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
IntegrationKey string `yaml:"integration-key"`
|
IntegrationKey string `yaml:"integration-key"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
IntegrationKey string `yaml:"integration-key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
return len(provider.IntegrationKey) == 32
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Either the default integration key has the right length, or there are overrides who are properly configured.
|
||||||
|
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
// Send an alert using the provider
|
||||||
//
|
//
|
||||||
// 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 *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
if alert.IsSendingOnResolved() {
|
||||||
|
if resolved {
|
||||||
|
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
|
||||||
|
alert.ResolveKey = ""
|
||||||
|
} else {
|
||||||
|
// We need to retrieve the resolve key from the response
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
var payload pagerDutyResponsePayload
|
||||||
|
if err = json.Unmarshal(body, &payload); err != nil {
|
||||||
|
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
||||||
|
log.Printf("[pagerduty][Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
|
||||||
|
} else {
|
||||||
|
alert.ResolveKey = payload.DedupKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
var message, eventAction, resolveKey string
|
var message, eventAction, resolveKey string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||||
eventAction = "resolve"
|
eventAction = "resolve"
|
||||||
resolveKey = alert.ResolveKey
|
resolveKey = alert.ResolveKey
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||||
eventAction = "trigger"
|
eventAction = "trigger"
|
||||||
resolveKey = ""
|
resolveKey = ""
|
||||||
}
|
}
|
||||||
return &custom.AlertProvider{
|
return fmt.Sprintf(`{
|
||||||
URL: restAPIURL,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: fmt.Sprintf(`{
|
|
||||||
"routing_key": "%s",
|
"routing_key": "%s",
|
||||||
"dedup_key": "%s",
|
"dedup_key": "%s",
|
||||||
"event_action": "%s",
|
"event_action": "%s",
|
||||||
@@ -52,14 +107,28 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
"source": "%s",
|
"source": "%s",
|
||||||
"severity": "critical"
|
"severity": "critical"
|
||||||
}
|
}
|
||||||
}`, provider.IntegrationKey, resolveKey, eventAction, message, service.Name),
|
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name)
|
||||||
Headers: map[string]string{
|
}
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
|
||||||
|
func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.IntegrationKey
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return provider.IntegrationKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pagerDutyResponsePayload struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
DedupKey string `json:"dedup_key"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package pagerduty
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -21,46 +22,225 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
Overrides: []Override{
|
||||||
if customAlertProvider == nil {
|
{
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
IntegrationKey: "00000000000000000000000000000000",
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
providerWithInvalidOverrideIntegrationKey := AlertProvider{
|
||||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
IntegrationKey: "",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
if providerWithInvalidOverrideIntegrationKey.IsValid() {
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
body := make(map[string]interface{})
|
providerWithValidOverride := AlertProvider{
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
Overrides: []Override{
|
||||||
if err != nil {
|
{
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
IntegrationKey: "00000000000000000000000000000000",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !providerWithValidOverride.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
defer client.InjectHTTPClient(nil)
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
|
firstDescription := "description-1"
|
||||||
if customAlertProvider == nil {
|
secondDescription := "description-2"
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
for _, scenario := range scenarios {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
}
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
err := scenario.Provider.Send(
|
||||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
}
|
&scenario.Alert,
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
&core.Result{
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
ConditionResults: []*core.ConditionResult{
|
||||||
}
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
body := make(map[string]interface{})
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
},
|
||||||
if err != nil {
|
},
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
description := "test"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||||
|
Alert: alert.Alert{Description: &description},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"\",\n \"event_action\": \"trigger\",\n \"payload\": {\n \"summary\": \"TRIGGERED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||||
|
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"key\",\n \"event_action\": \"resolve\",\n \"payload\": {\n \"summary\": \"RESOLVED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved)
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
ExpectedOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
IntegrationKey: "00000000000000000000000000000001",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "00000000000000000000000000000001",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
IntegrationKey: "00000000000000000000000000000001",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "00000000000000000000000000000001",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
IntegrationKey: "00000000000000000000000000000001",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
IntegrationKey: "00000000000000000000000000000002",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "00000000000000000000000000000001",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
IntegrationKey: "00000000000000000000000000000001",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
IntegrationKey: "00000000000000000000000000000002",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "00000000000000000000000000000002",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
package provider
|
package provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/alerting/provider/custom"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/discord"
|
"github.com/TwiN/gatus/v4/alerting/provider/discord"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v4/alerting/provider/email"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v4/alerting/provider/googlechat"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
"github.com/TwiN/gatus/v4/alerting/provider/matrix"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/teams"
|
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
"github.com/TwiN/gatus/v4/alerting/provider/ntfy"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/provider/slack"
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/provider/teams"
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
|
||||||
|
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the interface that each providers should implement
|
// AlertProvider is the interface that each providers should implement
|
||||||
@@ -19,32 +24,32 @@ type AlertProvider interface {
|
|||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
IsValid() bool
|
IsValid() bool
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
|
||||||
ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
GetDefaultAlert() *alert.Alert
|
GetDefaultAlert() *alert.Alert
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseWithDefaultAlert parses a service alert by using the provider's default alert as a baseline
|
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
|
||||||
func ParseWithDefaultAlert(providerDefaultAlert, serviceAlert *alert.Alert) {
|
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
||||||
if providerDefaultAlert == nil || serviceAlert == nil {
|
if providerDefaultAlert == nil || endpointAlert == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if serviceAlert.Enabled == nil {
|
if endpointAlert.Enabled == nil {
|
||||||
serviceAlert.Enabled = providerDefaultAlert.Enabled
|
endpointAlert.Enabled = providerDefaultAlert.Enabled
|
||||||
}
|
}
|
||||||
if serviceAlert.SendOnResolved == nil {
|
if endpointAlert.SendOnResolved == nil {
|
||||||
serviceAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
|
endpointAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
|
||||||
}
|
}
|
||||||
if serviceAlert.Description == nil {
|
if endpointAlert.Description == nil {
|
||||||
serviceAlert.Description = providerDefaultAlert.Description
|
endpointAlert.Description = providerDefaultAlert.Description
|
||||||
}
|
}
|
||||||
if serviceAlert.FailureThreshold == 0 {
|
if endpointAlert.FailureThreshold == 0 {
|
||||||
serviceAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
|
endpointAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
|
||||||
}
|
}
|
||||||
if serviceAlert.SuccessThreshold == 0 {
|
if endpointAlert.SuccessThreshold == 0 {
|
||||||
serviceAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
endpointAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +57,13 @@ var (
|
|||||||
// Validate interface implementation on compile
|
// Validate interface implementation on compile
|
||||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*ntfy.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*teams.AlertProvider)(nil)
|
_ AlertProvider = (*teams.AlertProvider)(nil)
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package provider
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseWithDefaultAlert(t *testing.T) {
|
func TestParseWithDefaultAlert(t *testing.T) {
|
||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
Name string
|
Name string
|
||||||
DefaultAlert, ServiceAlert, ExpectedOutputAlert *alert.Alert
|
DefaultAlert, EndpointAlert, ExpectedOutputAlert *alert.Alert
|
||||||
}
|
}
|
||||||
enabled := true
|
enabled := true
|
||||||
disabled := false
|
disabled := false
|
||||||
@@ -17,7 +17,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
scenarios := []Scenario{
|
scenarios := []Scenario{
|
||||||
{
|
{
|
||||||
Name: "service-alert-type-only",
|
Name: "endpoint-alert-type-only",
|
||||||
DefaultAlert: &alert.Alert{
|
DefaultAlert: &alert.Alert{
|
||||||
Enabled: &enabled,
|
Enabled: &enabled,
|
||||||
SendOnResolved: &enabled,
|
SendOnResolved: &enabled,
|
||||||
@@ -25,7 +25,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
FailureThreshold: 5,
|
FailureThreshold: 5,
|
||||||
SuccessThreshold: 10,
|
SuccessThreshold: 10,
|
||||||
},
|
},
|
||||||
ServiceAlert: &alert.Alert{
|
EndpointAlert: &alert.Alert{
|
||||||
Type: alert.TypeDiscord,
|
Type: alert.TypeDiscord,
|
||||||
},
|
},
|
||||||
ExpectedOutputAlert: &alert.Alert{
|
ExpectedOutputAlert: &alert.Alert{
|
||||||
@@ -38,7 +38,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "service-alert-overwrites-default-alert",
|
Name: "endpoint-alert-overwrites-default-alert",
|
||||||
DefaultAlert: &alert.Alert{
|
DefaultAlert: &alert.Alert{
|
||||||
Enabled: &disabled,
|
Enabled: &disabled,
|
||||||
SendOnResolved: &disabled,
|
SendOnResolved: &disabled,
|
||||||
@@ -46,7 +46,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
FailureThreshold: 5,
|
FailureThreshold: 5,
|
||||||
SuccessThreshold: 10,
|
SuccessThreshold: 10,
|
||||||
},
|
},
|
||||||
ServiceAlert: &alert.Alert{
|
EndpointAlert: &alert.Alert{
|
||||||
Type: alert.TypeTelegram,
|
Type: alert.TypeTelegram,
|
||||||
Enabled: &enabled,
|
Enabled: &enabled,
|
||||||
SendOnResolved: &enabled,
|
SendOnResolved: &enabled,
|
||||||
@@ -64,7 +64,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "service-alert-partially-overwrites-default-alert",
|
Name: "endpoint-alert-partially-overwrites-default-alert",
|
||||||
DefaultAlert: &alert.Alert{
|
DefaultAlert: &alert.Alert{
|
||||||
Enabled: &enabled,
|
Enabled: &enabled,
|
||||||
SendOnResolved: &enabled,
|
SendOnResolved: &enabled,
|
||||||
@@ -72,7 +72,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
FailureThreshold: 5,
|
FailureThreshold: 5,
|
||||||
SuccessThreshold: 10,
|
SuccessThreshold: 10,
|
||||||
},
|
},
|
||||||
ServiceAlert: &alert.Alert{
|
EndpointAlert: &alert.Alert{
|
||||||
Type: alert.TypeDiscord,
|
Type: alert.TypeDiscord,
|
||||||
Enabled: nil,
|
Enabled: nil,
|
||||||
SendOnResolved: nil,
|
SendOnResolved: nil,
|
||||||
@@ -98,7 +98,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
FailureThreshold: 5,
|
FailureThreshold: 5,
|
||||||
SuccessThreshold: 10,
|
SuccessThreshold: 10,
|
||||||
},
|
},
|
||||||
ServiceAlert: &alert.Alert{
|
EndpointAlert: &alert.Alert{
|
||||||
Type: alert.TypeDiscord,
|
Type: alert.TypeDiscord,
|
||||||
},
|
},
|
||||||
ExpectedOutputAlert: &alert.Alert{
|
ExpectedOutputAlert: &alert.Alert{
|
||||||
@@ -120,33 +120,33 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
FailureThreshold: 2,
|
FailureThreshold: 2,
|
||||||
SuccessThreshold: 5,
|
SuccessThreshold: 5,
|
||||||
},
|
},
|
||||||
ServiceAlert: nil,
|
EndpointAlert: nil,
|
||||||
ExpectedOutputAlert: nil,
|
ExpectedOutputAlert: nil,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.ServiceAlert)
|
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert)
|
||||||
if scenario.ExpectedOutputAlert == nil {
|
if scenario.ExpectedOutputAlert == nil {
|
||||||
if scenario.ServiceAlert != nil {
|
if scenario.EndpointAlert != nil {
|
||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if scenario.ServiceAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
|
if scenario.EndpointAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
|
||||||
t.Errorf("expected ServiceAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.ServiceAlert.IsEnabled())
|
t.Errorf("expected EndpointAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.EndpointAlert.IsEnabled())
|
||||||
}
|
}
|
||||||
if scenario.ServiceAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
|
if scenario.EndpointAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
|
||||||
t.Errorf("expected ServiceAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.ServiceAlert.IsSendingOnResolved())
|
t.Errorf("expected EndpointAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.EndpointAlert.IsSendingOnResolved())
|
||||||
}
|
}
|
||||||
if scenario.ServiceAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
|
if scenario.EndpointAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
|
||||||
t.Errorf("expected ServiceAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.ServiceAlert.GetDescription())
|
t.Errorf("expected EndpointAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.EndpointAlert.GetDescription())
|
||||||
}
|
}
|
||||||
if scenario.ServiceAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
|
if scenario.EndpointAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
|
||||||
t.Errorf("expected ServiceAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.ServiceAlert.FailureThreshold)
|
t.Errorf("expected EndpointAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.EndpointAlert.FailureThreshold)
|
||||||
}
|
}
|
||||||
if scenario.ServiceAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
if scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
||||||
t.Errorf("expected ServiceAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.ServiceAlert.SuccessThreshold)
|
t.Errorf("expected EndpointAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,72 @@
|
|||||||
package slack
|
package slack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
// Override is a case under which the default integration is overridden
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return len(provider.WebhookURL) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
var message, color, results string
|
var message, color, results string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||||
color = "#36A64F"
|
color = "#36A64F"
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
}
|
}
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
@@ -45,10 +82,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\\n> " + alertDescription
|
description = ":\\n> " + alertDescription
|
||||||
}
|
}
|
||||||
return &custom.AlertProvider{
|
return fmt.Sprintf(`{
|
||||||
URL: provider.WebhookURL,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: fmt.Sprintf(`{
|
|
||||||
"text": "",
|
"text": "",
|
||||||
"attachments": [
|
"attachments": [
|
||||||
{
|
{
|
||||||
@@ -65,9 +99,19 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`, message, description, color, results),
|
}`, message, description, color, results)
|
||||||
Headers: map[string]string{"Content-Type": "application/json"},
|
}
|
||||||
|
|
||||||
|
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||||
|
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.WebhookURL
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return provider.WebhookURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
|||||||
@@ -3,68 +3,275 @@ package slack
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||||
if invalidProvider.IsValid() {
|
if invalidProvider.IsValid() {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
validProvider := AlertProvider{WebhookURL: "https://example.com"}
|
||||||
if !validProvider.IsValid() {
|
if !validProvider.IsValid() {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
alertDescription := "test"
|
Overrides: []Override{
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
{
|
||||||
if customAlertProvider == nil {
|
WebhookURL: "http://example.com",
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
if customAlertProvider.URL != "http://example.com" {
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
WebhookURL: "",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
if providerWithInvalidOverrideTo.IsValid() {
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
body := make(map[string]interface{})
|
providerWithValidOverride := AlertProvider{
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
WebhookURL: "http://example.com",
|
||||||
if err != nil {
|
Overrides: []Override{
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
|
if !providerWithValidOverride.IsValid() {
|
||||||
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
defer client.InjectHTTPClient(nil)
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
firstDescription := "description-1"
|
||||||
if customAlertProvider == nil {
|
secondDescription := "description-2"
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
for _, scenario := range scenarios {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
}
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
if customAlertProvider.URL != "http://example.com" {
|
err := scenario.Provider.Send(
|
||||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
}
|
&scenario.Alert,
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
&core.Result{
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
ConditionResults: []*core.ConditionResult{
|
||||||
}
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
body := make(map[string]interface{})
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
},
|
||||||
if err != nil {
|
},
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Endpoint core.Endpoint
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: core.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-group",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: core.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: core.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-group",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: core.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
ExpectedOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example01.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.Name, func(t *testing.T) {
|
||||||
|
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||||
|
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,74 @@
|
|||||||
package teams
|
package teams
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Teams
|
// AlertProvider is the configuration necessary for sending an alert using Teams
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return len(provider.WebhookURL) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
var message string
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
var color string
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
|
var message, color string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||||
color = "#36A64F"
|
color = "#36A64F"
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
}
|
}
|
||||||
var results string
|
var results string
|
||||||
@@ -47,10 +85,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\\n> " + alertDescription
|
description = ":\\n> " + alertDescription
|
||||||
}
|
}
|
||||||
return &custom.AlertProvider{
|
return fmt.Sprintf(`{
|
||||||
URL: provider.WebhookURL,
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: fmt.Sprintf(`{
|
|
||||||
"@type": "MessageCard",
|
"@type": "MessageCard",
|
||||||
"@context": "http://schema.org/extensions",
|
"@context": "http://schema.org/extensions",
|
||||||
"themeColor": "%s",
|
"themeColor": "%s",
|
||||||
@@ -66,9 +101,19 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
"text": "%s"
|
"text": "%s"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`, color, message, description, service.URL, results),
|
}`, color, message, description, endpoint.URL, results)
|
||||||
Headers: map[string]string{"Content-Type": "application/json"},
|
}
|
||||||
|
|
||||||
|
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||||
|
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.WebhookURL
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return provider.WebhookURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ package teams
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||||
if invalidProvider.IsValid() {
|
if invalidProvider.IsValid() {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
@@ -21,50 +22,237 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
alertDescription := "test"
|
Overrides: []Override{
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
{
|
||||||
if customAlertProvider == nil {
|
WebhookURL: "http://example.com",
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
if customAlertProvider.URL != "http://example.org" {
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
WebhookURL: "",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
if providerWithInvalidOverrideTo.IsValid() {
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
body := make(map[string]interface{})
|
providerWithValidOverride := AlertProvider{
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
WebhookURL: "http://example.com",
|
||||||
if err != nil {
|
Overrides: []Override{
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["text"] {
|
if !providerWithValidOverride.IsValid() {
|
||||||
t.Errorf("expected $.text to be %s, got %s", expected, body["text"])
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
defer client.InjectHTTPClient(nil)
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
firstDescription := "description-1"
|
||||||
if customAlertProvider == nil {
|
secondDescription := "description-2"
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
for _, scenario := range scenarios {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
}
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
if customAlertProvider.URL != "http://example.org" {
|
err := scenario.Provider.Send(
|
||||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
}
|
&scenario.Alert,
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
&core.Result{
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
ConditionResults: []*core.ConditionResult{
|
||||||
}
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
body := make(map[string]interface{})
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
},
|
||||||
if err != nil {
|
},
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#DD0000\",\n \"title\": \"🚨 Gatus\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"❌ - `[CONNECTED] == true`<br/>❌ - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#36A64F\",\n \"title\": \"🚨 Gatus\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"✅ - `[CONNECTED] == true`<br/>✅ - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
ExpectedOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "http://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
WebhookURL: "http://example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "http://example01.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.Name, func(t *testing.T) {
|
||||||
|
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||||
|
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,69 @@
|
|||||||
package telegram
|
package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultAPIURL = "https://api.telegram.org"
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Telegram
|
// AlertProvider is the configuration necessary for sending an alert using Telegram
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
Token string `yaml:"token"`
|
Token string `yaml:"token"`
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
|
APIURL string `yaml:"api-url"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
if provider.ClientConfig == nil {
|
||||||
|
provider.ClientConfig = client.GetDefaultConfig()
|
||||||
|
}
|
||||||
return len(provider.Token) > 0 && len(provider.ID) > 0
|
return len(provider.Token) > 0 && len(provider.ID) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
|
apiURL := provider.APIURL
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = defaultAPIURL
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
var message, results string
|
var message, results string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", endpoint.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
@@ -46,12 +80,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
|||||||
} else {
|
} else {
|
||||||
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
|
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
|
||||||
}
|
}
|
||||||
return &custom.AlertProvider{
|
return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text)
|
||||||
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text),
|
|
||||||
Headers: map[string]string{"Content-Type": "application/json"},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
|||||||
@@ -2,90 +2,167 @@ package telegram
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{Token: "", ID: ""}
|
t.Run("invalid-provider", func(t *testing.T) {
|
||||||
if invalidProvider.IsValid() {
|
invalidProvider := AlertProvider{Token: "", ID: ""}
|
||||||
t.Error("provider shouldn't have been valid")
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("valid-provider", func(t *testing.T) {
|
||||||
|
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||||
|
if validProvider.ClientConfig != nil {
|
||||||
|
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
||||||
|
}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
if validProvider.ClientConfig == nil {
|
||||||
|
t.Error("provider client config should have been set after IsValid() was executed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
for _, scenario := range scenarios {
|
||||||
if !validProvider.IsValid() {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
t.Error("provider should've been valid")
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&core.Result{
|
||||||
|
ConditionResults: []*core.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
firstDescription := "description-1"
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
secondDescription := "description-2"
|
||||||
if customAlertProvider == nil {
|
scenarios := []struct {
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{ID: "123"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{ID: "123"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
for _, scenario := range scenarios {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
}
|
body := scenario.Provider.buildRequestBody(
|
||||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
&scenario.Alert,
|
||||||
}
|
&core.Result{
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
ConditionResults: []*core.ConditionResult{
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
}
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
body := make(map[string]interface{})
|
},
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
},
|
||||||
//_, err := json.Marshal(customAlertProvider.Body)
|
scenario.Resolved,
|
||||||
if err != nil {
|
)
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
description := "Healthcheck Successful"
|
t.Error("expected default alert to be not nil")
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{Description: &description}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
|
||||||
if customAlertProvider == nil {
|
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
|
||||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
|
||||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
|
||||||
}
|
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
|
||||||
}
|
|
||||||
body := make(map[string]interface{})
|
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
|
||||||
if err != nil {
|
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithDescription(t *testing.T) {
|
|
||||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
|
||||||
if customAlertProvider == nil {
|
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
|
||||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
|
||||||
}
|
|
||||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
|
||||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
|
||||||
}
|
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
|
||||||
}
|
|
||||||
body := make(map[string]interface{})
|
|
||||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
|
||||||
if err != nil {
|
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package twilio
|
package twilio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
"github.com/TwiN/gatus/v4/client"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
||||||
@@ -18,8 +20,8 @@ type AlertProvider struct {
|
|||||||
From string `yaml:"from"`
|
From string `yaml:"from"`
|
||||||
To string `yaml:"to"`
|
To string `yaml:"to"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// IsValid returns whether the provider's configuration is valid
|
||||||
@@ -27,27 +29,39 @@ func (provider *AlertProvider) IsValid() bool {
|
|||||||
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
|
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token))))
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
||||||
}
|
|
||||||
return &custom.AlertProvider{
|
|
||||||
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
Body: url.Values{
|
|
||||||
"To": {provider.To},
|
|
||||||
"From": {provider.From},
|
|
||||||
"Body": {message},
|
|
||||||
}.Encode(),
|
|
||||||
Headers: map[string]string{
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Authorization": fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", provider.SID, provider.Token)))),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
return url.Values{
|
||||||
|
"To": {provider.To},
|
||||||
|
"From": {provider.From},
|
||||||
|
"Body": {message},
|
||||||
|
}.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package twilio
|
package twilio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -25,54 +23,56 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
provider := AlertProvider{
|
firstDescription := "description-1"
|
||||||
SID: "1",
|
secondDescription := "description-2"
|
||||||
Token: "2",
|
scenarios := []struct {
|
||||||
From: "3",
|
Name string
|
||||||
To: "4",
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
description := "alert-description"
|
for _, scenario := range scenarios {
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, true)
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if customAlertProvider == nil {
|
body := scenario.Provider.buildRequestBody(
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
&core.Endpoint{Name: "endpoint-name"},
|
||||||
}
|
&scenario.Alert,
|
||||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
&core.Result{
|
||||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
ConditionResults: []*core.ConditionResult{
|
||||||
}
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json" {
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json", customAlertProvider.URL)
|
},
|
||||||
}
|
},
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
scenario.Resolved,
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
)
|
||||||
}
|
if body != scenario.ExpectedBody {
|
||||||
if customAlertProvider.Body != "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4" {
|
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||||
t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4", customAlertProvider.Body)
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
provider := AlertProvider{
|
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
SID: "4",
|
t.Error("expected default alert to be not nil")
|
||||||
Token: "3",
|
|
||||||
From: "2",
|
|
||||||
To: "1",
|
|
||||||
}
|
}
|
||||||
description := "alert-description"
|
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, false)
|
t.Error("expected default alert to be nil")
|
||||||
if customAlertProvider == nil {
|
|
||||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
|
||||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
|
||||||
}
|
|
||||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json" {
|
|
||||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json", customAlertProvider.URL)
|
|
||||||
}
|
|
||||||
if customAlertProvider.Method != http.MethodPost {
|
|
||||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
|
||||||
}
|
|
||||||
if customAlertProvider.Body != "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1" {
|
|
||||||
t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1", customAlertProvider.Body)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,21 @@ import (
|
|||||||
"github.com/go-ping/ping"
|
"github.com/go-ping/ping"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetHTTPClient returns the shared HTTP client
|
// injectedHTTPClient is used for testing purposes
|
||||||
|
var injectedHTTPClient *http.Client
|
||||||
|
|
||||||
|
// GetHTTPClient returns the shared HTTP client, or the client from the configuration passed
|
||||||
func GetHTTPClient(config *Config) *http.Client {
|
func GetHTTPClient(config *Config) *http.Client {
|
||||||
|
if injectedHTTPClient != nil {
|
||||||
|
return injectedHTTPClient
|
||||||
|
}
|
||||||
if config == nil {
|
if config == nil {
|
||||||
return defaultConfig.getHTTPClient()
|
return defaultConfig.getHTTPClient()
|
||||||
}
|
}
|
||||||
return config.getHTTPClient()
|
return config.getHTTPClient()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanCreateTCPConnection checks whether a connection can be established with a TCP service
|
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
|
||||||
func CanCreateTCPConnection(address string, config *Config) bool {
|
func CanCreateTCPConnection(address string, config *Config) bool {
|
||||||
conn, err := net.DialTimeout("tcp", address, config.Timeout)
|
conn, err := net.DialTimeout("tcp", address, config.Timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -38,7 +44,11 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
|
|||||||
if len(hostAndPort) != 2 {
|
if len(hostAndPort) != 2 {
|
||||||
return false, nil, errors.New("invalid address for starttls, format must be host:port")
|
return false, nil, errors.New("invalid address for starttls, format must be host:port")
|
||||||
}
|
}
|
||||||
smtpClient, err := smtp.Dial(address)
|
connection, err := net.DialTimeout("tcp", address, config.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
smtpClient, err := smtp.NewClient(connection, hostAndPort[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,6 +67,20 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
|
|||||||
return true, certificate, nil
|
return true, certificate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
|
||||||
|
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
|
||||||
|
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
verifiedChains := connection.ConnectionState().VerifiedChains
|
||||||
|
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return true, verifiedChains[0][0], nil
|
||||||
|
}
|
||||||
|
|
||||||
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
||||||
//
|
//
|
||||||
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
|
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
|
||||||
@@ -67,8 +91,11 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
|||||||
}
|
}
|
||||||
pinger.Count = 1
|
pinger.Count = 1
|
||||||
pinger.Timeout = config.Timeout
|
pinger.Timeout = config.Timeout
|
||||||
// Set the pinger's privileged mode to true for every operating system except darwin
|
// Set the pinger's privileged mode to true for every GOOS except darwin
|
||||||
// https://github.com/TwinProduction/gatus/issues/132
|
// See https://github.com/TwiN/gatus/issues/132
|
||||||
|
//
|
||||||
|
// Note that for this to work on Linux, Gatus must run with sudo privileges.
|
||||||
|
// See https://github.com/go-ping/ping#linux
|
||||||
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
||||||
err = pinger.Run()
|
err = pinger.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,3 +110,8 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
|||||||
}
|
}
|
||||||
return true, 0
|
return true, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
|
||||||
|
func InjectHTTPClient(httpClient *http.Client) {
|
||||||
|
injectedHTTPClient = httpClient
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetHTTPClient(t *testing.T) {
|
func TestGetHTTPClient(t *testing.T) {
|
||||||
@@ -10,8 +15,18 @@ func TestGetHTTPClient(t *testing.T) {
|
|||||||
Insecure: false,
|
Insecure: false,
|
||||||
IgnoreRedirect: false,
|
IgnoreRedirect: false,
|
||||||
Timeout: 0,
|
Timeout: 0,
|
||||||
|
DNSResolver: "tcp://1.1.1.1:53",
|
||||||
|
OAuth2Config: &OAuth2Config{
|
||||||
|
ClientID: "00000000-0000-0000-0000-000000000000",
|
||||||
|
ClientSecret: "secretsauce",
|
||||||
|
TokenURL: "https://token-server.local/token",
|
||||||
|
Scopes: []string{"https://application.local/.default"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := cfg.ValidateAndSetDefaults()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected error to be nil, but got: `%s`", err)
|
||||||
}
|
}
|
||||||
cfg.ValidateAndSetDefaults()
|
|
||||||
if GetHTTPClient(cfg) == nil {
|
if GetHTTPClient(cfg) == nil {
|
||||||
t.Error("expected client to not be nil")
|
t.Error("expected client to not be nil")
|
||||||
}
|
}
|
||||||
@@ -91,8 +106,116 @@ func TestCanPerformStartTLS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanPerformTLS(t *testing.T) {
|
||||||
|
type args struct {
|
||||||
|
address string
|
||||||
|
insecure bool
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
wantConnected bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid address",
|
||||||
|
args: args{
|
||||||
|
address: "test",
|
||||||
|
},
|
||||||
|
wantConnected: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error dial",
|
||||||
|
args: args{
|
||||||
|
address: "test:1234",
|
||||||
|
},
|
||||||
|
wantConnected: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid tls",
|
||||||
|
args: args{
|
||||||
|
address: "smtp.gmail.com:465",
|
||||||
|
},
|
||||||
|
wantConnected: true,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if connected != tt.wantConnected {
|
||||||
|
t.Errorf("CanPerformTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCanCreateTCPConnection(t *testing.T) {
|
func TestCanCreateTCPConnection(t *testing.T) {
|
||||||
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
|
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
|
||||||
t.Error("should've failed, because there's no port in the address")
|
t.Error("should've failed, because there's no port in the address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This test checks if a HTTP client configured with `configureOAuth2()` automatically
|
||||||
|
// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization`
|
||||||
|
// header to all outgoing HTTP calls.
|
||||||
|
func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
|
||||||
|
defer InjectHTTPClient(nil)
|
||||||
|
oAuth2Config := &OAuth2Config{
|
||||||
|
ClientID: "00000000-0000-0000-0000-000000000000",
|
||||||
|
ClientSecret: "secretsauce",
|
||||||
|
TokenURL: "https://token-server.local/token",
|
||||||
|
Scopes: []string{"https://application.local/.default"},
|
||||||
|
}
|
||||||
|
mockHttpClient := &http.Client{
|
||||||
|
Transport: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
// if the mock HTTP client tries to get a token from the `token-server`
|
||||||
|
// we provide the expected token response
|
||||||
|
if r.Host == "token-server.local" {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(
|
||||||
|
[]byte(
|
||||||
|
`{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// to verify the headers were sent as expected, we echo them back in the
|
||||||
|
// `X-Org-Authorization` header and check if the token value matches our
|
||||||
|
// mocked `token-server` response
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: map[string][]string{
|
||||||
|
"X-Org-Authorization": {r.Header.Get("Authorization")},
|
||||||
|
},
|
||||||
|
Body: http.NoBody,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
mockHttpClientWithOAuth := configureOAuth2(mockHttpClient, *oAuth2Config)
|
||||||
|
InjectHTTPClient(mockHttpClientWithOAuth)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:8282", http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
response, err := mockHttpClientWithOAuth.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if response.Header == nil {
|
||||||
|
t.Error("expected response headers, but got nil")
|
||||||
|
}
|
||||||
|
// the mock response echos the Authorization header used in the request back
|
||||||
|
// to us as `X-Org-Authorization` header, we check here if the value matches
|
||||||
|
// our expected token `secret-token`
|
||||||
|
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
|
||||||
|
t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
136
client/config.go
@@ -1,9 +1,18 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/clientcredentials"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -11,7 +20,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// DefaultConfig is the default client configuration
|
ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
|
||||||
|
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
|
||||||
|
ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
|
||||||
|
|
||||||
defaultConfig = Config{
|
defaultConfig = Config{
|
||||||
Insecure: false,
|
Insecure: false,
|
||||||
IgnoreRedirect: false,
|
IgnoreRedirect: false,
|
||||||
@@ -28,25 +40,102 @@ func GetDefaultConfig() *Config {
|
|||||||
// Config is the configuration for clients
|
// Config is the configuration for clients
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Insecure determines whether to skip verifying the server's certificate chain and host name
|
// Insecure determines whether to skip verifying the server's certificate chain and host name
|
||||||
Insecure bool `yaml:"insecure"`
|
Insecure bool `yaml:"insecure,omitempty"`
|
||||||
|
|
||||||
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
|
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
|
||||||
IgnoreRedirect bool `yaml:"ignore-redirect"`
|
IgnoreRedirect bool `yaml:"ignore-redirect,omitempty"`
|
||||||
|
|
||||||
// Timeout for the client
|
// Timeout for the client
|
||||||
Timeout time.Duration `yaml:"timeout"`
|
Timeout time.Duration `yaml:"timeout"`
|
||||||
|
|
||||||
|
// DNSResolver override for the HTTP client
|
||||||
|
// Expected format is {protocol}://{host}:{port}, e.g. tcp://1.1.1.1:53
|
||||||
|
DNSResolver string `yaml:"dns-resolver,omitempty"`
|
||||||
|
|
||||||
|
// OAuth2Config is the OAuth2 configuration used for the client.
|
||||||
|
//
|
||||||
|
// If non-nil, the http.Client returned by getHTTPClient will automatically retrieve a token if necessary.
|
||||||
|
// See configureOAuth2 for more details.
|
||||||
|
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
|
||||||
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
|
||||||
|
type DNSResolverConfig struct {
|
||||||
|
Protocol string
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth2Config is the configuration for the OAuth2 client credentials flow
|
||||||
|
type OAuth2Config struct {
|
||||||
|
TokenURL string `yaml:"token-url"` // e.g. https://dev-12345678.okta.com/token
|
||||||
|
ClientID string `yaml:"client-id"`
|
||||||
|
ClientSecret string `yaml:"client-secret"`
|
||||||
|
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
|
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
|
||||||
func (c *Config) ValidateAndSetDefaults() {
|
func (c *Config) ValidateAndSetDefaults() error {
|
||||||
if c.Timeout < time.Millisecond {
|
if c.Timeout < time.Millisecond {
|
||||||
c.Timeout = 10 * time.Second
|
c.Timeout = 10 * time.Second
|
||||||
}
|
}
|
||||||
|
if c.HasCustomDNSResolver() {
|
||||||
|
// Validate the DNS resolver now to make sure it will not return an error later.
|
||||||
|
if _, err := c.parseDNSResolver(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
|
||||||
|
return ErrInvalidClientOAuth2Config
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHTTPClient return a HTTP client matching the Config's parameters.
|
// HasCustomDNSResolver returns whether a custom DNSResolver is configured
|
||||||
|
func (c *Config) HasCustomDNSResolver() bool {
|
||||||
|
return len(c.DNSResolver) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDNSResolver parses the DNS resolver into the DNSResolverConfig struct
|
||||||
|
func (c *Config) parseDNSResolver() (*DNSResolverConfig, error) {
|
||||||
|
re := regexp.MustCompile(`^(?P<proto>(.*))://(?P<host>[A-Za-z0-9\-\.]+):(?P<port>[0-9]+)?(.*)$`)
|
||||||
|
matches := re.FindStringSubmatch(c.DNSResolver)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, ErrInvalidDNSResolver
|
||||||
|
}
|
||||||
|
r := make(map[string]string)
|
||||||
|
for i, k := range re.SubexpNames() {
|
||||||
|
if i != 0 && k != "" {
|
||||||
|
r[k] = matches[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(r["port"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
return nil, ErrInvalidDNSResolverPort
|
||||||
|
}
|
||||||
|
return &DNSResolverConfig{
|
||||||
|
Protocol: r["proto"],
|
||||||
|
Host: r["host"],
|
||||||
|
Port: r["port"],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasOAuth2Config returns true if the client has OAuth2 configuration parameters
|
||||||
|
func (c *Config) HasOAuth2Config() bool {
|
||||||
|
return c.OAuth2Config != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValid() returns true if the OAuth2 configuration is valid
|
||||||
|
func (c *OAuth2Config) isValid() bool {
|
||||||
|
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHTTPClient return an HTTP client matching the Config's parameters.
|
||||||
func (c *Config) getHTTPClient() *http.Client {
|
func (c *Config) getHTTPClient() *http.Client {
|
||||||
if c.httpClient == nil {
|
if c.httpClient == nil {
|
||||||
c.httpClient = &http.Client{
|
c.httpClient = &http.Client{
|
||||||
@@ -68,6 +157,43 @@ func (c *Config) getHTTPClient() *http.Client {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if c.HasCustomDNSResolver() {
|
||||||
|
dnsResolver, err := c.parseDNSResolver()
|
||||||
|
if err != nil {
|
||||||
|
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
|
||||||
|
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
|
||||||
|
log.Println("[client][getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
|
||||||
|
} else {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Resolver: &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
d := net.Dialer{}
|
||||||
|
return d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+":"+dnsResolver.Port)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.httpClient.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return dialer.DialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.HasOAuth2Config() {
|
||||||
|
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return c.httpClient
|
return c.httpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
|
||||||
|
// The returned Client and its Transport should not be modified.
|
||||||
|
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
|
||||||
|
oauth2cfg := clientcredentials.Config{
|
||||||
|
ClientID: c.ClientID,
|
||||||
|
ClientSecret: c.ClientSecret,
|
||||||
|
Scopes: c.Scopes,
|
||||||
|
TokenURL: c.TokenURL,
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
|
||||||
|
return oauth2cfg.Client(ctx)
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,3 +35,47 @@ func TestConfig_getHTTPClient(t *testing.T) {
|
|||||||
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
|
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
|
||||||
|
type args struct {
|
||||||
|
dnsResolver string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with-valid-resolver",
|
||||||
|
args: args{
|
||||||
|
dnsResolver: "tcp://1.1.1.1:53",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-invalid-resolver-port",
|
||||||
|
args: args{
|
||||||
|
dnsResolver: "tcp://127.0.0.1:99999",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-invalid-resolver-format",
|
||||||
|
args: args{
|
||||||
|
dnsResolver: "foobar",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
DNSResolver: tt.args.dnsResolver,
|
||||||
|
}
|
||||||
|
err := cfg.ValidateAndSetDefaults()
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ValidateAndSetDefaults() error=%v, wantErr=%v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
14
config.yaml
@@ -1,8 +1,8 @@
|
|||||||
services:
|
endpoints:
|
||||||
- name: front-end
|
- name: front-end
|
||||||
group: core
|
group: core
|
||||||
url: "https://twinnation.org/health"
|
url: "https://twin.sh/health"
|
||||||
interval: 1m
|
interval: 5m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
- "[BODY].status == UP"
|
- "[BODY].status == UP"
|
||||||
@@ -31,7 +31,7 @@ services:
|
|||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
- name: example-dns-query
|
- name: example-dns-query
|
||||||
url: "8.8.8.8" # Address of the DNS server to use
|
url: "1.1.1.1" # Address of the DNS server to use
|
||||||
interval: 5m
|
interval: 5m
|
||||||
dns:
|
dns:
|
||||||
query-name: "example.com"
|
query-name: "example.com"
|
||||||
@@ -45,3 +45,9 @@ services:
|
|||||||
interval: 1m
|
interval: 1m
|
||||||
conditions:
|
conditions:
|
||||||
- "[CONNECTED] == true"
|
- "[CONNECTED] == true"
|
||||||
|
|
||||||
|
- name: check-domain-expiration
|
||||||
|
url: "https://example.org/"
|
||||||
|
interval: 1h
|
||||||
|
conditions:
|
||||||
|
- "[DOMAIN_EXPIRATION] > 720h"
|
||||||
|
|||||||
194
config/config.go
@@ -2,17 +2,22 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io/ioutil"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/alerting"
|
"github.com/TwiN/gatus/v4/alerting"
|
||||||
"github.com/TwinProduction/gatus/alerting/alert"
|
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||||
"github.com/TwinProduction/gatus/alerting/provider"
|
"github.com/TwiN/gatus/v4/alerting/provider"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/config/maintenance"
|
||||||
"github.com/TwinProduction/gatus/security"
|
"github.com/TwiN/gatus/v4/config/remote"
|
||||||
"github.com/TwinProduction/gatus/storage"
|
"github.com/TwiN/gatus/v4/config/ui"
|
||||||
|
"github.com/TwiN/gatus/v4/config/web"
|
||||||
|
"github.com/TwiN/gatus/v4/core"
|
||||||
|
"github.com/TwiN/gatus/v4/security"
|
||||||
|
"github.com/TwiN/gatus/v4/storage"
|
||||||
|
"github.com/TwiN/gatus/v4/util"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,17 +29,11 @@ const (
|
|||||||
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
|
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
|
||||||
// configuration file if DefaultConfigurationFilePath didn't work
|
// configuration file if DefaultConfigurationFilePath didn't work
|
||||||
DefaultFallbackConfigurationFilePath = "config/config.yml"
|
DefaultFallbackConfigurationFilePath = "config/config.yml"
|
||||||
|
|
||||||
// DefaultAddress is the default address the service will bind to
|
|
||||||
DefaultAddress = "0.0.0.0"
|
|
||||||
|
|
||||||
// DefaultPort is the default port the service will listen on
|
|
||||||
DefaultPort = 8080
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrNoServiceInConfig is an error returned when a configuration file has no services configured
|
// ErrNoEndpointInConfig is an error returned when a configuration file has no endpoints configured
|
||||||
ErrNoServiceInConfig = errors.New("configuration file should contain at least 1 service")
|
ErrNoEndpointInConfig = errors.New("configuration file should contain at least 1 endpoint")
|
||||||
|
|
||||||
// ErrConfigFileNotFound is an error returned when the configuration file could not be found
|
// ErrConfigFileNotFound is an error returned when the configuration file could not be found
|
||||||
ErrConfigFileNotFound = errors.New("configuration file not found")
|
ErrConfigFileNotFound = errors.New("configuration file not found")
|
||||||
@@ -46,39 +45,67 @@ var (
|
|||||||
// Config is the main configuration structure
|
// Config is the main configuration structure
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Debug Whether to enable debug logs
|
// Debug Whether to enable debug logs
|
||||||
Debug bool `yaml:"debug"`
|
Debug bool `yaml:"debug,omitempty"`
|
||||||
|
|
||||||
// Metrics Whether to expose metrics at /metrics
|
// Metrics Whether to expose metrics at /metrics
|
||||||
Metrics bool `yaml:"metrics"`
|
Metrics bool `yaml:"metrics,omitempty"`
|
||||||
|
|
||||||
// SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration
|
// SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration
|
||||||
// if the configuration file is updated while the application is running
|
// if the configuration file is updated while the application is running
|
||||||
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update"`
|
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update,omitempty"`
|
||||||
|
|
||||||
// DisableMonitoringLock Whether to disable the monitoring lock
|
// DisableMonitoringLock Whether to disable the monitoring lock
|
||||||
// The monitoring lock is what prevents multiple services from being processed at the same time.
|
// The monitoring lock is what prevents multiple endpoints from being processed at the same time.
|
||||||
// Disabling this may lead to inaccurate response times
|
// Disabling this may lead to inaccurate response times
|
||||||
DisableMonitoringLock bool `yaml:"disable-monitoring-lock"`
|
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
|
||||||
|
|
||||||
// Security Configuration for securing access to Gatus
|
// Security Configuration for securing access to Gatus
|
||||||
Security *security.Config `yaml:"security"`
|
Security *security.Config `yaml:"security,omitempty"`
|
||||||
|
|
||||||
// Alerting Configuration for alerting
|
// Alerting Configuration for alerting
|
||||||
Alerting *alerting.Config `yaml:"alerting"`
|
Alerting *alerting.Config `yaml:"alerting,omitempty"`
|
||||||
|
|
||||||
// Services List of services to monitor
|
// Endpoints List of endpoints to monitor
|
||||||
Services []*core.Service `yaml:"services"`
|
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
|
||||||
|
|
||||||
|
// Services List of endpoints to monitor
|
||||||
|
//
|
||||||
|
// XXX: Remove this in v5.0.0
|
||||||
|
// XXX: This is not a typo -- not v4.0.0, but v5.0.0 -- I want to give enough time for people to migrate
|
||||||
|
//
|
||||||
|
// Deprecated in favor of Endpoints
|
||||||
|
Services []*core.Endpoint `yaml:"services,omitempty"`
|
||||||
|
|
||||||
// Storage is the configuration for how the data is stored
|
// Storage is the configuration for how the data is stored
|
||||||
Storage *storage.Config `yaml:"storage"`
|
Storage *storage.Config `yaml:"storage,omitempty"`
|
||||||
|
|
||||||
// Web is the configuration for the web listener
|
// Web is the web configuration for the application
|
||||||
Web *WebConfig `yaml:"web"`
|
Web *web.Config `yaml:"web,omitempty"`
|
||||||
|
|
||||||
|
// UI is the configuration for the UI
|
||||||
|
UI *ui.Config `yaml:"ui,omitempty"`
|
||||||
|
|
||||||
|
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent
|
||||||
|
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
|
||||||
|
|
||||||
|
// Remote is the configuration for remote Gatus instances
|
||||||
|
// WARNING: This is in ALPHA and may change or be completely removed in the future
|
||||||
|
Remote *remote.Config `yaml:"remote,omitempty"`
|
||||||
|
|
||||||
filePath string // path to the file from which config was loaded from
|
filePath string // path to the file from which config was loaded from
|
||||||
lastFileModTime time.Time // last modification time
|
lastFileModTime time.Time // last modification time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
|
||||||
|
for i := 0; i < len(config.Endpoints); i++ {
|
||||||
|
ep := config.Endpoints[i]
|
||||||
|
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key {
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// HasLoadedConfigurationFileBeenModified returns whether the file that the
|
// HasLoadedConfigurationFileBeenModified returns whether the file that the
|
||||||
// configuration has been loaded from has been modified since it was last read
|
// configuration has been loaded from has been modified since it was last read
|
||||||
func (config Config) HasLoadedConfigurationFileBeenModified() bool {
|
func (config Config) HasLoadedConfigurationFileBeenModified() bool {
|
||||||
@@ -131,85 +158,120 @@ func LoadDefaultConfiguration() (*Config, error) {
|
|||||||
|
|
||||||
func readConfigurationFile(fileName string) (config *Config, err error) {
|
func readConfigurationFile(fileName string) (config *Config, err error) {
|
||||||
var bytes []byte
|
var bytes []byte
|
||||||
if bytes, err = ioutil.ReadFile(fileName); err == nil {
|
if bytes, err = os.ReadFile(fileName); err == nil {
|
||||||
// file exists, so we'll parse it and return it
|
// file exists, so we'll parse it and return it
|
||||||
return parseAndValidateConfigBytes(bytes)
|
return parseAndValidateConfigBytes(bytes)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
|
||||||
func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||||
// Expand environment variables
|
// Expand environment variables
|
||||||
yamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))
|
yamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))
|
||||||
// Parse configuration file
|
// Parse configuration file
|
||||||
err = yaml.Unmarshal(yamlBytes, &config)
|
if err = yaml.Unmarshal(yamlBytes, &config); err != nil {
|
||||||
if err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Check if the configuration file at least has services configured
|
if config != nil && len(config.Services) > 0 { // XXX: Remove this in v5.0.0
|
||||||
if config == nil || config.Services == nil || len(config.Services) == 0 {
|
log.Println("WARNING: Your configuration is using 'services:', which is deprecated in favor of 'endpoints:'.")
|
||||||
err = ErrNoServiceInConfig
|
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/191 for more information")
|
||||||
|
config.Endpoints = append(config.Endpoints, config.Services...)
|
||||||
|
config.Services = nil
|
||||||
|
}
|
||||||
|
// Check if the configuration file at least has endpoints configured
|
||||||
|
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
|
||||||
|
err = ErrNoEndpointInConfig
|
||||||
} else {
|
} else {
|
||||||
// Note that the functions below may panic, and this is on purpose to prevent Gatus from starting with
|
validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug)
|
||||||
// invalid configurations
|
|
||||||
validateAlertingConfig(config.Alerting, config.Services, config.Debug)
|
|
||||||
if err := validateSecurityConfig(config); err != nil {
|
if err := validateSecurityConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateServicesConfig(config); err != nil {
|
if err := validateEndpointsConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateWebConfig(config); err != nil {
|
if err := validateWebConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := validateUIConfig(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateMaintenanceConfig(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := validateStorageConfig(config); err != nil {
|
if err := validateStorageConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := validateRemoteConfig(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRemoteConfig(config *Config) error {
|
||||||
|
if config.Remote != nil {
|
||||||
|
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateStorageConfig(config *Config) error {
|
func validateStorageConfig(config *Config) error {
|
||||||
if config.Storage == nil {
|
if config.Storage == nil {
|
||||||
config.Storage = &storage.Config{
|
config.Storage = &storage.Config{
|
||||||
Type: storage.TypeMemory,
|
Type: storage.TypeMemory,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if err := config.Storage.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
err := storage.Initialize(config.Storage)
|
return nil
|
||||||
if err != nil {
|
}
|
||||||
return err
|
|
||||||
|
func validateMaintenanceConfig(config *Config) error {
|
||||||
|
if config.Maintenance == nil {
|
||||||
|
config.Maintenance = maintenance.GetDefaultConfig()
|
||||||
|
} else {
|
||||||
|
if err := config.Maintenance.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Remove all ServiceStatus that represent services which no longer exist in the configuration
|
return nil
|
||||||
var keys []string
|
}
|
||||||
for _, service := range config.Services {
|
|
||||||
keys = append(keys, service.Key())
|
func validateUIConfig(config *Config) error {
|
||||||
}
|
if config.UI == nil {
|
||||||
numberOfServiceStatusesDeleted := storage.Get().DeleteAllServiceStatusesNotInKeys(keys)
|
config.UI = ui.GetDefaultConfig()
|
||||||
if numberOfServiceStatusesDeleted > 0 {
|
} else {
|
||||||
log.Printf("[config][validateStorageConfig] Deleted %d service statuses because their matching services no longer existed", numberOfServiceStatusesDeleted)
|
if err := config.UI.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateWebConfig(config *Config) error {
|
func validateWebConfig(config *Config) error {
|
||||||
if config.Web == nil {
|
if config.Web == nil {
|
||||||
config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort}
|
config.Web = web.GetDefaultConfig()
|
||||||
} else {
|
} else {
|
||||||
return config.Web.validateAndSetDefaults()
|
return config.Web.ValidateAndSetDefaults()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateServicesConfig(config *Config) error {
|
func validateEndpointsConfig(config *Config) error {
|
||||||
for _, service := range config.Services {
|
for _, endpoint := range config.Endpoints {
|
||||||
if config.Debug {
|
if config.Debug {
|
||||||
log.Printf("[config][validateServicesConfig] Validating service '%s'", service.Name)
|
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
|
||||||
}
|
}
|
||||||
if err := service.ValidateAndSetDefaults(); err != nil {
|
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||||
return err
|
return fmt.Errorf("invalid endpoint %s: %s", endpoint.DisplayName(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services))
|
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,10 +291,10 @@ func validateSecurityConfig(config *Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateAlertingConfig validates the alerting configuration
|
// validateAlertingConfig validates the alerting configuration
|
||||||
// Note that the alerting configuration has to be validated before the service configuration, because the default alert
|
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
|
||||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Service.ValidateAndSetDefaults()
|
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Endpoint.ValidateAndSetDefaults()
|
||||||
// sets the default alert values when none are set.
|
// sets the default alert values when none are set.
|
||||||
func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Service, debug bool) {
|
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.Endpoint, debug bool) {
|
||||||
if alertingConfig == nil {
|
if alertingConfig == nil {
|
||||||
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
|
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
|
||||||
return
|
return
|
||||||
@@ -240,8 +302,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Se
|
|||||||
alertTypes := []alert.Type{
|
alertTypes := []alert.Type{
|
||||||
alert.TypeCustom,
|
alert.TypeCustom,
|
||||||
alert.TypeDiscord,
|
alert.TypeDiscord,
|
||||||
|
alert.TypeEmail,
|
||||||
|
alert.TypeMatrix,
|
||||||
alert.TypeMattermost,
|
alert.TypeMattermost,
|
||||||
alert.TypeMessagebird,
|
alert.TypeMessagebird,
|
||||||
|
alert.TypeNtfy,
|
||||||
|
alert.TypeOpsgenie,
|
||||||
alert.TypePagerDuty,
|
alert.TypePagerDuty,
|
||||||
alert.TypeSlack,
|
alert.TypeSlack,
|
||||||
alert.TypeTeams,
|
alert.TypeTeams,
|
||||||
@@ -255,13 +321,13 @@ func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Se
|
|||||||
if alertProvider.IsValid() {
|
if alertProvider.IsValid() {
|
||||||
// Parse alerts with the provider's default alert
|
// Parse alerts with the provider's default alert
|
||||||
if alertProvider.GetDefaultAlert() != nil {
|
if alertProvider.GetDefaultAlert() != nil {
|
||||||
for _, service := range services {
|
for _, endpoint := range endpoints {
|
||||||
for alertIndex, serviceAlert := range service.Alerts {
|
for alertIndex, endpointAlert := range endpoint.Alerts {
|
||||||
if alertType == serviceAlert.Type {
|
if alertType == endpointAlert.Type {
|
||||||
if debug {
|
if debug {
|
||||||
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in service=%s", alertIndex, alertType, service.Name)
|
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name)
|
||||||
}
|
}
|
||||||
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), serviceAlert)
|
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
config/maintenance/maintenance.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package maintenance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidMaintenanceStartFormat = errors.New("invalid maintenance start format: must be hh:mm, between 00:00 and 23:59 inclusively (e.g. 23:00)")
|
||||||
|
errInvalidMaintenanceDuration = errors.New("invalid maintenance duration: must be bigger than 0 (e.g. 30m)")
|
||||||
|
errInvalidDayName = fmt.Errorf("invalid value specified for 'on'. supported values are %s", longDayNames)
|
||||||
|
|
||||||
|
longDayNames = []string{
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config allows for the configuration of a maintenance period.
|
||||||
|
// During this maintenance period, no alerts will be sent.
|
||||||
|
//
|
||||||
|
// Uses UTC.
|
||||||
|
type Config struct {
|
||||||
|
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
|
||||||
|
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
|
||||||
|
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
|
||||||
|
|
||||||
|
// Every is a list of days of the week during which maintenance period applies.
|
||||||
|
// See longDayNames for list of valid values.
|
||||||
|
// Every day if empty.
|
||||||
|
Every []string `yaml:"every"`
|
||||||
|
|
||||||
|
durationToStartFromMidnight time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDefaultConfig() *Config {
|
||||||
|
defaultValue := false
|
||||||
|
return &Config{
|
||||||
|
Enabled: &defaultValue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns whether maintenance is enabled or not
|
||||||
|
func (c Config) IsEnabled() bool {
|
||||||
|
if c.Enabled == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *c.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSetDefaults validates the maintenance configuration and sets the default values if necessary.
|
||||||
|
//
|
||||||
|
// Must be called once in the application's lifecycle before IsUnderMaintenance is called, since it
|
||||||
|
// also sets durationToStartFromMidnight.
|
||||||
|
func (c *Config) ValidateAndSetDefaults() error {
|
||||||
|
if c == nil || !c.IsEnabled() {
|
||||||
|
// Don't waste time validating if maintenance is not enabled.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, day := range c.Every {
|
||||||
|
isDayValid := false
|
||||||
|
for _, longDayName := range longDayNames {
|
||||||
|
if day == longDayName {
|
||||||
|
isDayValid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isDayValid {
|
||||||
|
return errInvalidDayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
c.durationToStartFromMidnight, err = hhmmToDuration(c.Start)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.Duration <= 0 || c.Duration >= 24*time.Hour {
|
||||||
|
return errInvalidMaintenanceDuration
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUnderMaintenance checks whether the endpoints that Gatus monitors are within the configured maintenance window
|
||||||
|
func (c Config) IsUnderMaintenance() bool {
|
||||||
|
if !c.IsEnabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
var dayWhereMaintenancePeriodWouldStart time.Time
|
||||||
|
if now.Hour() >= int(c.durationToStartFromMidnight.Hours()) {
|
||||||
|
dayWhereMaintenancePeriodWouldStart = now.Truncate(24 * time.Hour)
|
||||||
|
} else {
|
||||||
|
dayWhereMaintenancePeriodWouldStart = now.Add(-c.Duration).Truncate(24 * time.Hour)
|
||||||
|
}
|
||||||
|
hasMaintenanceEveryDay := len(c.Every) == 0
|
||||||
|
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String())
|
||||||
|
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
|
||||||
|
// The day when the maintenance period would start is not scheduled
|
||||||
|
// to have any maintenance, so we can just return false.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
startOfMaintenancePeriod := dayWhereMaintenancePeriodWouldStart.Add(c.durationToStartFromMidnight)
|
||||||
|
endOfMaintenancePeriod := startOfMaintenancePeriod.Add(c.Duration)
|
||||||
|
return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) hasDay(day string) bool {
|
||||||
|
for _, d := range c.Every {
|
||||||
|
if d == day {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hhmmToDuration(s string) (time.Duration, error) {
|
||||||
|
if len(s) != 5 {
|
||||||
|
return 0, errInvalidMaintenanceStartFormat
|
||||||
|
}
|
||||||
|
var hours, minutes int
|
||||||
|
var err error
|
||||||
|
if hours, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[:2]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if minutes, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[3:5]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
duration := (time.Duration(hours) * time.Hour) + (time.Duration(minutes) * time.Minute)
|
||||||
|
if hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || duration < 0 || duration >= 24*time.Hour {
|
||||||
|
return 0, errInvalidMaintenanceStartFormat
|
||||||
|
}
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractNumericalValueFromPotentiallyZeroPaddedString(s string) (int, error) {
|
||||||
|
return strconv.Atoi(strings.TrimPrefix(s, "0"))
|
||||||
|
}
|
||||||
261
config/maintenance/maintenance_test.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package maintenance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDefaultConfig(t *testing.T) {
|
||||||
|
if *GetDefaultConfig().Enabled {
|
||||||
|
t.Fatal("expected default config to be disabled by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||||
|
yes, no := true, false
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
cfg *Config
|
||||||
|
expectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil",
|
||||||
|
cfg: nil,
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled",
|
||||||
|
cfg: &Config{
|
||||||
|
Enabled: &no,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-day",
|
||||||
|
cfg: &Config{
|
||||||
|
Every: []string{"invalid-day"},
|
||||||
|
},
|
||||||
|
expectedError: errInvalidDayName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-day",
|
||||||
|
cfg: &Config{
|
||||||
|
Every: []string{"invalid-day"},
|
||||||
|
},
|
||||||
|
expectedError: errInvalidDayName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-start-format",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: "0000",
|
||||||
|
},
|
||||||
|
expectedError: errInvalidMaintenanceStartFormat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-start-hours",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: "25:00",
|
||||||
|
},
|
||||||
|
expectedError: errInvalidMaintenanceStartFormat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-start-minutes",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: "0:61",
|
||||||
|
},
|
||||||
|
expectedError: errInvalidMaintenanceStartFormat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-start-minutes-non-numerical",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: "00:zz",
|
||||||
|
},
|
||||||
|
expectedError: strconv.ErrSyntax,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-start-hours-non-numerical",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: "zz:00",
|
||||||
|
},
|
||||||
|
expectedError: strconv.ErrSyntax,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-duration",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: "23:00",
|
||||||
|
Duration: 0,
|
||||||
|
},
|
||||||
|
expectedError: errInvalidMaintenanceDuration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "every-day-at-2300",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: "23:00",
|
||||||
|
Duration: time.Hour,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "every-day-explicitly-at-2300",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: "23:00",
|
||||||
|
Duration: time.Hour,
|
||||||
|
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "every-monday-at-0000",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: "00:00",
|
||||||
|
Duration: 30 * time.Minute,
|
||||||
|
Every: []string{"Monday"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "every-friday-and-sunday-at-0000-explicitly-enabled",
|
||||||
|
cfg: &Config{
|
||||||
|
Enabled: &yes,
|
||||||
|
Start: "08:00",
|
||||||
|
Duration: 8 * time.Hour,
|
||||||
|
Every: []string{"Friday", "Sunday"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.cfg.ValidateAndSetDefaults()
|
||||||
|
if !errors.Is(err, scenario.expectedError) {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expectedError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||||
|
yes, no := true, false
|
||||||
|
now := time.Now().UTC()
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
cfg *Config
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disabled",
|
||||||
|
cfg: &Config{
|
||||||
|
Enabled: &no,
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "under-maintenance-explicitly-enabled",
|
||||||
|
cfg: &Config{
|
||||||
|
Enabled: &yes,
|
||||||
|
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||||
|
Duration: 2 * time.Hour,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "under-maintenance-starting-now-for-2h",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||||
|
Duration: 2 * time.Hour,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "under-maintenance-starting-now-for-8h",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||||
|
Duration: 8 * time.Hour,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "under-maintenance-starting-now-for-8h-explicit-days",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||||
|
Duration: 8 * time.Hour,
|
||||||
|
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "under-maintenance-starting-now-for-23h-explicit-days",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||||
|
Duration: 23 * time.Hour,
|
||||||
|
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "under-maintenance-starting-4h-ago-for-8h",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||||
|
Duration: 8 * time.Hour,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "under-maintenance-starting-22h-ago-for-23h",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
|
||||||
|
Duration: 23 * time.Hour,
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "under-maintenance-starting-4h-ago-for-3h",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||||
|
Duration: 3 * time.Hour,
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "under-maintenance-starting-5h-ago-for-1h",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)),
|
||||||
|
Duration: time.Hour,
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not-under-maintenance-today",
|
||||||
|
cfg: &Config{
|
||||||
|
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||||
|
Duration: time.Hour,
|
||||||
|
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
t.Log(scenario.cfg.Start)
|
||||||
|
t.Log(now)
|
||||||
|
if err := scenario.cfg.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Fatal("validation shouldn't have returned an error, got", err)
|
||||||
|
}
|
||||||
|
isUnderMaintenance := scenario.cfg.IsUnderMaintenance()
|
||||||
|
if isUnderMaintenance != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, isUnderMaintenance)
|
||||||
|
t.Logf("start=%v; duration=%v; now=%v", scenario.cfg.Start, scenario.cfg.Duration, time.Now().UTC())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHour(hour int) int {
|
||||||
|
if hour < 0 {
|
||||||
|
return hour + 24
|
||||||
|
}
|
||||||
|
return hour
|
||||||
|
}
|
||||||
39
config/remote/remote.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package remote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.
|
||||||
|
// For more information, see https://github.com/TwiN/gatus/issues/64
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
// Instances is a list of remote instances to retrieve endpoint statuses from.
|
||||||
|
Instances []Instance `yaml:"instances,omitempty"`
|
||||||
|
|
||||||
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Instance struct {
|
||||||
|
EndpointPrefix string `yaml:"endpoint-prefix"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) ValidateAndSetDefaults() error {
|
||||||
|
if c.ClientConfig == nil {
|
||||||
|
c.ClientConfig = client.GetDefaultConfig()
|
||||||
|
} else {
|
||||||
|
if err := c.ClientConfig.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.Instances) > 0 {
|
||||||
|
log.Println("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
|
||||||
|
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
|
||||||
|
log.Println("WARNING: This feature is a candidate for removal in future versions. Please comment on the issue above if you need this feature.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
82
config/ui/ui.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTitle = "Health Dashboard | Gatus"
|
||||||
|
defaultHeader = "Health Status"
|
||||||
|
defaultLogo = ""
|
||||||
|
defaultLink = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the configuration for the UI of Gatus
|
||||||
|
type Config struct {
|
||||||
|
Title string `yaml:"title,omitempty"` // Title of the page
|
||||||
|
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
|
||||||
|
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||||
|
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||||
|
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button is the configuration for a button on the UI
|
||||||
|
type Button struct {
|
||||||
|
Name string `yaml:"name,omitempty"` // Name is the text to display on the button
|
||||||
|
Link string `yaml:"link,omitempty"` // Link to open when the button is clicked.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the button configuration
|
||||||
|
func (btn *Button) Validate() error {
|
||||||
|
if len(btn.Name) == 0 || len(btn.Link) == 0 {
|
||||||
|
return ErrButtonValidationFailed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultConfig returns a Config struct with the default values
|
||||||
|
func GetDefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Title: defaultTitle,
|
||||||
|
Header: defaultHeader,
|
||||||
|
Logo: defaultLogo,
|
||||||
|
Link: defaultLink,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSetDefaults validates the UI configuration and sets the default values if necessary.
|
||||||
|
func (cfg *Config) ValidateAndSetDefaults() error {
|
||||||
|
if len(cfg.Title) == 0 {
|
||||||
|
cfg.Title = defaultTitle
|
||||||
|
}
|
||||||
|
if len(cfg.Header) == 0 {
|
||||||
|
cfg.Header = defaultHeader
|
||||||
|
}
|
||||||
|
if len(cfg.Header) == 0 {
|
||||||
|
cfg.Header = defaultLink
|
||||||
|
}
|
||||||
|
for _, btn := range cfg.Buttons {
|
||||||
|
if err := btn.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validate that the template works
|
||||||
|
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
err = t.Execute(&buffer, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
73
config/ui/ui_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Title: "",
|
||||||
|
Header: "",
|
||||||
|
Logo: "",
|
||||||
|
Link: "",
|
||||||
|
}
|
||||||
|
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if cfg.Title != defaultTitle {
|
||||||
|
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
|
||||||
|
}
|
||||||
|
if cfg.Header != defaultHeader {
|
||||||
|
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestButton_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name, Link string
|
||||||
|
ExpectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "",
|
||||||
|
Link: "",
|
||||||
|
ExpectedError: ErrButtonValidationFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "",
|
||||||
|
Link: "link",
|
||||||
|
ExpectedError: ErrButtonValidationFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "name",
|
||||||
|
Link: "",
|
||||||
|
ExpectedError: ErrButtonValidationFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "name",
|
||||||
|
Link: "link",
|
||||||
|
ExpectedError: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i, scenario := range scenarios {
|
||||||
|
t.Run(strconv.Itoa(i)+"_"+scenario.Name+"_"+scenario.Link, func(t *testing.T) {
|
||||||
|
button := &Button{
|
||||||
|
Name: scenario.Name,
|
||||||
|
Link: scenario.Link,
|
||||||
|
}
|
||||||
|
if err := button.Validate(); err != scenario.ExpectedError {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDefaultConfig(t *testing.T) {
|
||||||
|
defaultConfig := GetDefaultConfig()
|
||||||
|
if defaultConfig.Title != defaultTitle {
|
||||||
|
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
|
||||||
|
}
|
||||||
|
if defaultConfig.Logo != defaultLogo {
|
||||||
|
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
package config
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebConfig is the structure which supports the configuration of the endpoint
|
const (
|
||||||
|
// DefaultAddress is the default address the application will bind to
|
||||||
|
DefaultAddress = "0.0.0.0"
|
||||||
|
|
||||||
|
// DefaultPort is the default port the application will listen on
|
||||||
|
DefaultPort = 8080
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the structure which supports the configuration of the endpoint
|
||||||
// which provides access to the web frontend
|
// which provides access to the web frontend
|
||||||
type WebConfig struct {
|
type Config struct {
|
||||||
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
|
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
|
||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
|
|
||||||
@@ -15,8 +23,13 @@ type WebConfig struct {
|
|||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateAndSetDefaults checks and sets the default values for fields that are not set
|
// GetDefaultConfig returns a Config struct with the default values
|
||||||
func (web *WebConfig) validateAndSetDefaults() error {
|
func GetDefaultConfig() *Config {
|
||||||
|
return &Config{Address: DefaultAddress, Port: DefaultPort}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSetDefaults validates the web configuration and sets the default values if necessary.
|
||||||
|
func (web *Config) ValidateAndSetDefaults() error {
|
||||||
// Validate the Address
|
// Validate the Address
|
||||||
if len(web.Address) == 0 {
|
if len(web.Address) == 0 {
|
||||||
web.Address = DefaultAddress
|
web.Address = DefaultAddress
|
||||||
@@ -31,6 +44,6 @@ func (web *WebConfig) validateAndSetDefaults() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SocketAddress returns the combination of the Address and the Port
|
// SocketAddress returns the combination of the Address and the Port
|
||||||
func (web *WebConfig) SocketAddress() string {
|
func (web *Config) SocketAddress() string {
|
||||||
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
||||||
}
|
}
|
||||||
65
config/web/web_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDefaultConfig(t *testing.T) {
|
||||||
|
defaultConfig := GetDefaultConfig()
|
||||||
|
if defaultConfig.Port != DefaultPort {
|
||||||
|
t.Error("expected default config to have the default port")
|
||||||
|
}
|
||||||
|
if defaultConfig.Address != DefaultAddress {
|
||||||
|
t.Error("expected default config to have the default address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
cfg *Config
|
||||||
|
expectedAddress string
|
||||||
|
expectedPort int
|
||||||
|
expectedErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no-explicit-config",
|
||||||
|
cfg: &Config{},
|
||||||
|
expectedAddress: "0.0.0.0",
|
||||||
|
expectedPort: 8080,
|
||||||
|
expectedErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-port",
|
||||||
|
cfg: &Config{Port: 100000000},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.cfg.ValidateAndSetDefaults()
|
||||||
|
if (err != nil) != scenario.expectedErr {
|
||||||
|
t.Errorf("expected the existence of an error to be %v, got %v", scenario.expectedErr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !scenario.expectedErr {
|
||||||
|
if scenario.cfg.Port != scenario.expectedPort {
|
||||||
|
t.Errorf("expected port to be %d, got %d", scenario.expectedPort, scenario.cfg.Port)
|
||||||
|
}
|
||||||
|
if scenario.cfg.Address != scenario.expectedAddress {
|
||||||
|
t.Errorf("expected address to be %s, got %s", scenario.expectedAddress, scenario.cfg.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_SocketAddress(t *testing.T) {
|
||||||
|
web := &Config{
|
||||||
|
Address: "0.0.0.0",
|
||||||
|
Port: 8081,
|
||||||
|
}
|
||||||
|
if web.SocketAddress() != "0.0.0.0:8081" {
|
||||||
|
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWebConfig_SocketAddress(t *testing.T) {
|
|
||||||
web := &WebConfig{
|
|
||||||
Address: "0.0.0.0",
|
|
||||||
Port: 8081,
|
|
||||||
}
|
|
||||||
if web.SocketAddress() != "0.0.0.0:8081" {
|
|
||||||
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/storage"
|
|
||||||
"github.com/TwinProduction/gatus/storage/store/common"
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
badgeColorHexAwesome = "#40cc11"
|
|
||||||
badgeColorHexGreat = "#94cc11"
|
|
||||||
badgeColorHexGood = "#ccd311"
|
|
||||||
badgeColorHexPassable = "#ccb311"
|
|
||||||
badgeColorHexBad = "#cc8111"
|
|
||||||
badgeColorHexVeryBad = "#c7130a"
|
|
||||||
)
|
|
||||||
|
|
||||||
// uptimeBadgeHandler handles the automatic generation of badge based on the group name and service name passed.
|
|
||||||
//
|
|
||||||
// Valid values for {duration}: 7d, 24h, 1h
|
|
||||||
func uptimeBadgeHandler(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
variables := mux.Vars(request)
|
|
||||||
duration := variables["duration"]
|
|
||||||
var from time.Time
|
|
||||||
switch duration {
|
|
||||||
case "7d":
|
|
||||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
|
||||||
case "24h":
|
|
||||||
from = time.Now().Add(-24 * time.Hour)
|
|
||||||
case "1h":
|
|
||||||
from = time.Now().Add(-time.Hour)
|
|
||||||
default:
|
|
||||||
writer.WriteHeader(http.StatusBadRequest)
|
|
||||||
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := variables["key"]
|
|
||||||
uptime, err := storage.Get().GetUptimeByKey(key, from, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
if err == common.ErrServiceNotFound {
|
|
||||||
writer.WriteHeader(http.StatusNotFound)
|
|
||||||
} else if err == common.ErrInvalidTimeRange {
|
|
||||||
writer.WriteHeader(http.StatusBadRequest)
|
|
||||||
} else {
|
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
_, _ = writer.Write([]byte(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
formattedDate := time.Now().Format(http.TimeFormat)
|
|
||||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
writer.Header().Set("Date", formattedDate)
|
|
||||||
writer.Header().Set("Expires", formattedDate)
|
|
||||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
|
||||||
_, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime))
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
|
||||||
var labelWidth, valueWidth, valueWidthAdjustment int
|
|
||||||
switch duration {
|
|
||||||
case "7d":
|
|
||||||
labelWidth = 65
|
|
||||||
case "24h":
|
|
||||||
labelWidth = 70
|
|
||||||
case "1h":
|
|
||||||
labelWidth = 65
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
color := getBadgeColorFromUptime(uptime)
|
|
||||||
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", uptime*100), "0"), ".") + "%"
|
|
||||||
if strings.Contains(sanitizedValue, ".") {
|
|
||||||
valueWidthAdjustment = -10
|
|
||||||
}
|
|
||||||
valueWidth = (len(sanitizedValue) * 11) + valueWidthAdjustment
|
|
||||||
width := labelWidth + valueWidth
|
|
||||||
labelX := labelWidth / 2
|
|
||||||
valueX := labelWidth + (valueWidth / 2)
|
|
||||||
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
|
|
||||||
<linearGradient id="b" x2="0" y2="100%%">
|
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
|
||||||
</linearGradient>
|
|
||||||
<mask id="a">
|
|
||||||
<rect width="%d" height="20" rx="3" fill="#fff"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#a)">
|
|
||||||
<path fill="#555" d="M0 0h%dv20H0z"/>
|
|
||||||
<path fill="%s" d="M%d 0h%dv20H%dz"/>
|
|
||||||
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
|
|
||||||
</g>
|
|
||||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
|
||||||
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
|
||||||
uptime %s
|
|
||||||
</text>
|
|
||||||
<text x="%d" y="14">
|
|
||||||
uptime %s
|
|
||||||
</text>
|
|
||||||
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
|
||||||
%s
|
|
||||||
</text>
|
|
||||||
<text x="%d" y="14">
|
|
||||||
%s
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
|
|
||||||
return svg
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBadgeColorFromUptime(uptime float64) string {
|
|
||||||
if uptime >= 0.975 {
|
|
||||||
return badgeColorHexAwesome
|
|
||||||
} else if uptime >= 0.95 {
|
|
||||||
return badgeColorHexGreat
|
|
||||||
} else if uptime >= 0.9 {
|
|
||||||
return badgeColorHexGood
|
|
||||||
} else if uptime >= 0.8 {
|
|
||||||
return badgeColorHexPassable
|
|
||||||
} else if uptime >= 0.65 {
|
|
||||||
return badgeColorHexBad
|
|
||||||
}
|
|
||||||
return badgeColorHexVeryBad
|
|
||||||
}
|
|
||||||
|
|
||||||
// responseTimeBadgeHandler handles the automatic generation of badge based on the group name and service name passed.
|
|
||||||
//
|
|
||||||
// Valid values for {duration}: 7d, 24h, 1h
|
|
||||||
func responseTimeBadgeHandler(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
variables := mux.Vars(request)
|
|
||||||
duration := variables["duration"]
|
|
||||||
var from time.Time
|
|
||||||
switch duration {
|
|
||||||
case "7d":
|
|
||||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
|
||||||
case "24h":
|
|
||||||
from = time.Now().Add(-24 * time.Hour)
|
|
||||||
case "1h":
|
|
||||||
from = time.Now().Add(-time.Hour)
|
|
||||||
default:
|
|
||||||
writer.WriteHeader(http.StatusBadRequest)
|
|
||||||
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := variables["key"]
|
|
||||||
averageResponseTime, err := storage.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
if err == common.ErrServiceNotFound {
|
|
||||||
writer.WriteHeader(http.StatusNotFound)
|
|
||||||
} else if err == common.ErrInvalidTimeRange {
|
|
||||||
writer.WriteHeader(http.StatusBadRequest)
|
|
||||||
} else {
|
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
_, _ = writer.Write([]byte(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
formattedDate := time.Now().Format(http.TimeFormat)
|
|
||||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
writer.Header().Set("Date", formattedDate)
|
|
||||||
writer.Header().Set("Expires", formattedDate)
|
|
||||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
|
||||||
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime))
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []byte {
|
|
||||||
var labelWidth, valueWidth int
|
|
||||||
switch duration {
|
|
||||||
case "7d":
|
|
||||||
labelWidth = 105
|
|
||||||
case "24h":
|
|
||||||
labelWidth = 110
|
|
||||||
case "1h":
|
|
||||||
labelWidth = 105
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
color := getBadgeColorFromResponseTime(averageResponseTime)
|
|
||||||
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
|
|
||||||
valueWidth = len(sanitizedValue) * 11
|
|
||||||
width := labelWidth + valueWidth
|
|
||||||
labelX := labelWidth / 2
|
|
||||||
valueX := labelWidth + (valueWidth / 2)
|
|
||||||
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
|
|
||||||
<linearGradient id="b" x2="0" y2="100%%">
|
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
|
||||||
</linearGradient>
|
|
||||||
<mask id="a">
|
|
||||||
<rect width="%d" height="20" rx="3" fill="#fff"/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#a)">
|
|
||||||
<path fill="#555" d="M0 0h%dv20H0z"/>
|
|
||||||
<path fill="%s" d="M%d 0h%dv20H%dz"/>
|
|
||||||
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
|
|
||||||
</g>
|
|
||||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
|
||||||
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
|
||||||
response time %s
|
|
||||||
</text>
|
|
||||||
<text x="%d" y="14">
|
|
||||||
response time %s
|
|
||||||
</text>
|
|
||||||
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
|
||||||
%s
|
|
||||||
</text>
|
|
||||||
<text x="%d" y="14">
|
|
||||||
%s
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
|
|
||||||
return svg
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBadgeColorFromResponseTime(responseTime int) string {
|
|
||||||
if responseTime <= 50 {
|
|
||||||
return badgeColorHexAwesome
|
|
||||||
} else if responseTime <= 200 {
|
|
||||||
return badgeColorHexGreat
|
|
||||||
} else if responseTime <= 300 {
|
|
||||||
return badgeColorHexGood
|
|
||||||
} else if responseTime <= 500 {
|
|
||||||
return badgeColorHexPassable
|
|
||||||
} else if responseTime <= 750 {
|
|
||||||
return badgeColorHexBad
|
|
||||||
}
|
|
||||||
return badgeColorHexVeryBad
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetBadgeColorFromUptime(t *testing.T) {
|
|
||||||
scenarios := []struct {
|
|
||||||
Uptime float64
|
|
||||||
ExpectedColor string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
Uptime: 1,
|
|
||||||
ExpectedColor: badgeColorHexAwesome,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Uptime: 0.99,
|
|
||||||
ExpectedColor: badgeColorHexAwesome,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Uptime: 0.97,
|
|
||||||
ExpectedColor: badgeColorHexGreat,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Uptime: 0.95,
|
|
||||||
ExpectedColor: badgeColorHexGreat,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Uptime: 0.93,
|
|
||||||
ExpectedColor: badgeColorHexGood,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Uptime: 0.9,
|
|
||||||
ExpectedColor: badgeColorHexGood,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Uptime: 0.85,
|
|
||||||
ExpectedColor: badgeColorHexPassable,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Uptime: 0.7,
|
|
||||||
ExpectedColor: badgeColorHexBad,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Uptime: 0.65,
|
|
||||||
ExpectedColor: badgeColorHexBad,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Uptime: 0.6,
|
|
||||||
ExpectedColor: badgeColorHexVeryBad,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
t.Run("uptime-"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) {
|
|
||||||
if getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor {
|
|
||||||
t.Errorf("expected %s from %f, got %v", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
|
||||||
scenarios := []struct {
|
|
||||||
ResponseTime int
|
|
||||||
ExpectedColor string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
ResponseTime: 10,
|
|
||||||
ExpectedColor: badgeColorHexAwesome,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ResponseTime: 50,
|
|
||||||
ExpectedColor: badgeColorHexAwesome,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ResponseTime: 75,
|
|
||||||
ExpectedColor: badgeColorHexGreat,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ResponseTime: 150,
|
|
||||||
ExpectedColor: badgeColorHexGreat,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ResponseTime: 201,
|
|
||||||
ExpectedColor: badgeColorHexGood,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ResponseTime: 300,
|
|
||||||
ExpectedColor: badgeColorHexGood,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ResponseTime: 301,
|
|
||||||
ExpectedColor: badgeColorHexPassable,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ResponseTime: 450,
|
|
||||||
ExpectedColor: badgeColorHexPassable,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ResponseTime: 700,
|
|
||||||
ExpectedColor: badgeColorHexBad,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ResponseTime: 1500,
|
|
||||||
ExpectedColor: badgeColorHexVeryBad,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
|
|
||||||
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor {
|
|
||||||
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +1,37 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/config"
|
"github.com/TwiN/gatus/v4/config"
|
||||||
"github.com/TwinProduction/gatus/security"
|
"github.com/TwiN/gatus/v4/controller/handler"
|
||||||
"github.com/TwinProduction/gatus/storage"
|
|
||||||
"github.com/TwinProduction/gatus/storage/store/common"
|
|
||||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
|
||||||
"github.com/TwinProduction/gocache"
|
|
||||||
"github.com/TwinProduction/health"
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
cacheTTL = 10 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
|
||||||
|
|
||||||
// staticFolder is the path to the location of the static folder from the root path of the project
|
|
||||||
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
|
|
||||||
staticFolder = "./web/static"
|
|
||||||
|
|
||||||
// server is the http.Server created by Handle.
|
// server is the http.Server created by Handle.
|
||||||
// The only reason it exists is for testing purposes.
|
// The only reason it exists is for testing purposes.
|
||||||
server *http.Server
|
server *http.Server
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handle creates the router and starts the server
|
// Handle creates the router and starts the server
|
||||||
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, enableMetrics bool) {
|
func Handle(cfg *config.Config) {
|
||||||
var router http.Handler = CreateRouter(securityConfig, enableMetrics)
|
var router http.Handler = handler.CreateRouter(cfg)
|
||||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||||
router = developmentCorsHandler(router)
|
router = handler.DevelopmentCORS(router)
|
||||||
}
|
}
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
|
Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
|
||||||
Handler: router,
|
Handler: router,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 15 * time.Second,
|
WriteTimeout: 15 * time.Second,
|
||||||
IdleTimeout: 15 * time.Second,
|
IdleTimeout: 15 * time.Second,
|
||||||
}
|
}
|
||||||
log.Println("[controller][Handle] Listening on " + webConfig.SocketAddress())
|
log.Println("[controller][Handle] Listening on " + cfg.Web.SocketAddress())
|
||||||
if os.Getenv("ROUTER_TEST") == "true" {
|
if os.Getenv("ROUTER_TEST") == "true" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -66,98 +45,3 @@ func Shutdown() {
|
|||||||
server = nil
|
server = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateRouter creates the router for the http server
|
|
||||||
func CreateRouter(securityConfig *security.Config, enabledMetrics bool) *mux.Router {
|
|
||||||
router := mux.NewRouter()
|
|
||||||
if enabledMetrics {
|
|
||||||
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
|
|
||||||
}
|
|
||||||
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
|
||||||
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET")
|
|
||||||
// New endpoints
|
|
||||||
router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
|
|
||||||
router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).Methods("GET")
|
|
||||||
// TODO: router.HandleFunc("/api/v1/services/{key}/uptimes", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceUptimesHandler))).Methods("GET")
|
|
||||||
// TODO: router.HandleFunc("/api/v1/services/{key}/events", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceEventsHandler))).Methods("GET")
|
|
||||||
router.HandleFunc("/api/v1/services/{key}/uptimes/{duration}/badge.svg", uptimeBadgeHandler).Methods("GET")
|
|
||||||
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", responseTimeBadgeHandler).Methods("GET")
|
|
||||||
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", responseTimeChartHandler).Methods("GET")
|
|
||||||
// SPA
|
|
||||||
router.HandleFunc("/services/{service}", spaHandler).Methods("GET")
|
|
||||||
// Everything else falls back on static content
|
|
||||||
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
|
|
||||||
if securityConfig != nil && securityConfig.IsValid() {
|
|
||||||
return security.Handler(handler, securityConfig)
|
|
||||||
}
|
|
||||||
return handler
|
|
||||||
}
|
|
||||||
|
|
||||||
// serviceStatusesHandler handles requests to retrieve all service statuses
|
|
||||||
// Due to the size of the response, this function leverages a cache.
|
|
||||||
// Must not be wrapped by GzipHandler
|
|
||||||
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
|
||||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
|
||||||
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
|
|
||||||
var exists bool
|
|
||||||
var value interface{}
|
|
||||||
if gzipped {
|
|
||||||
writer.Header().Set("Content-Encoding", "gzip")
|
|
||||||
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize))
|
|
||||||
} else {
|
|
||||||
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d", page, pageSize))
|
|
||||||
}
|
|
||||||
var data []byte
|
|
||||||
if !exists {
|
|
||||||
var err error
|
|
||||||
buffer := &bytes.Buffer{}
|
|
||||||
gzipWriter := gzip.NewWriter(buffer)
|
|
||||||
data, err = json.Marshal(storage.Get().GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(page, pageSize)))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[controller][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()
|
|
||||||
gzippedData := buffer.Bytes()
|
|
||||||
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d", page, pageSize), data, cacheTTL)
|
|
||||||
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
|
|
||||||
if gzipped {
|
|
||||||
data = gzippedData
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data = value.([]byte)
|
|
||||||
}
|
|
||||||
writer.Header().Add("Content-Type", "application/json")
|
|
||||||
writer.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = writer.Write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// serviceStatusHandler retrieves a single ServiceStatus by group name and service name
|
|
||||||
func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
|
|
||||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
|
||||||
if serviceStatus == nil {
|
|
||||||
log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"])
|
|
||||||
writer.WriteHeader(http.StatusNotFound)
|
|
||||||
_, _ = writer.Write([]byte("not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output, err := json.Marshal(serviceStatus)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[controller][serviceStatusHandler] Unable to marshal object to JSON: %s", err.Error())
|
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = writer.Write([]byte("unable to marshal object to JSON"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writer.Header().Add("Content-Type", "application/json")
|
|
||||||
writer.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = writer.Write(output)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,274 +6,19 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/config"
|
"github.com/TwiN/gatus/v4/config"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"github.com/TwiN/gatus/v4/config/web"
|
||||||
"github.com/TwinProduction/gatus/storage"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
"github.com/TwinProduction/gatus/watchdog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
firstCondition = core.Condition("[STATUS] == 200")
|
|
||||||
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
|
|
||||||
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
|
||||||
|
|
||||||
timestamp = time.Now()
|
|
||||||
|
|
||||||
testService = core.Service{
|
|
||||||
Name: "name",
|
|
||||||
Group: "group",
|
|
||||||
URL: "https://example.org/what/ever",
|
|
||||||
Method: "GET",
|
|
||||||
Body: "body",
|
|
||||||
Interval: 30 * time.Second,
|
|
||||||
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
|
|
||||||
Alerts: nil,
|
|
||||||
NumberOfFailuresInARow: 0,
|
|
||||||
NumberOfSuccessesInARow: 0,
|
|
||||||
}
|
|
||||||
testSuccessfulResult = core.Result{
|
|
||||||
Hostname: "example.org",
|
|
||||||
IP: "127.0.0.1",
|
|
||||||
HTTPStatus: 200,
|
|
||||||
Errors: nil,
|
|
||||||
Connected: true,
|
|
||||||
Success: true,
|
|
||||||
Timestamp: timestamp,
|
|
||||||
Duration: 150 * time.Millisecond,
|
|
||||||
CertificateExpiration: 10 * time.Hour,
|
|
||||||
ConditionResults: []*core.ConditionResult{
|
|
||||||
{
|
|
||||||
Condition: "[STATUS] == 200",
|
|
||||||
Success: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "[RESPONSE_TIME] < 500",
|
|
||||||
Success: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
|
||||||
Success: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
testUnsuccessfulResult = core.Result{
|
|
||||||
Hostname: "example.org",
|
|
||||||
IP: "127.0.0.1",
|
|
||||||
HTTPStatus: 200,
|
|
||||||
Errors: []string{"error-1", "error-2"},
|
|
||||||
Connected: true,
|
|
||||||
Success: false,
|
|
||||||
Timestamp: timestamp,
|
|
||||||
Duration: 750 * time.Millisecond,
|
|
||||||
CertificateExpiration: 10 * time.Hour,
|
|
||||||
ConditionResults: []*core.ConditionResult{
|
|
||||||
{
|
|
||||||
Condition: "[STATUS] == 200",
|
|
||||||
Success: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "[RESPONSE_TIME] < 500",
|
|
||||||
Success: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
|
||||||
Success: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateRouter(t *testing.T) {
|
|
||||||
defer storage.Get().Clear()
|
|
||||||
defer cache.Clear()
|
|
||||||
staticFolder = "../web/static"
|
|
||||||
cfg := &config.Config{
|
|
||||||
Metrics: true,
|
|
||||||
Services: []*core.Service{
|
|
||||||
{
|
|
||||||
Name: "frontend",
|
|
||||||
Group: "core",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "backend",
|
|
||||||
Group: "core",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
|
||||||
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
|
||||||
router := CreateRouter(cfg.Security, cfg.Metrics)
|
|
||||||
type Scenario struct {
|
|
||||||
Name string
|
|
||||||
Path string
|
|
||||||
ExpectedCode int
|
|
||||||
Gzip bool
|
|
||||||
}
|
|
||||||
scenarios := []Scenario{
|
|
||||||
{
|
|
||||||
Name: "health",
|
|
||||||
Path: "/health",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "metrics",
|
|
||||||
Path: "/metrics",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-uptime-1h",
|
|
||||||
Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-uptime-24h",
|
|
||||||
Path: "/api/v1/services/core_backend/uptimes/24h/badge.svg",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-uptime-7d",
|
|
||||||
Path: "/api/v1/services/core_frontend/uptimes/7d/badge.svg",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-uptime-with-invalid-duration",
|
|
||||||
Path: "/api/v1/services/core_backend/uptimes/3d/badge.svg",
|
|
||||||
ExpectedCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-uptime-for-invalid-key",
|
|
||||||
Path: "/api/v1/services/invalid_key/uptimes/7d/badge.svg",
|
|
||||||
ExpectedCode: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-response-time-1h",
|
|
||||||
Path: "/api/v1/services/core_frontend/response-times/1h/badge.svg",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-response-time-24h",
|
|
||||||
Path: "/api/v1/services/core_backend/response-times/24h/badge.svg",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-response-time-7d",
|
|
||||||
Path: "/api/v1/services/core_frontend/response-times/7d/badge.svg",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-response-time-with-invalid-duration",
|
|
||||||
Path: "/api/v1/services/core_backend/response-times/3d/badge.svg",
|
|
||||||
ExpectedCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "badge-response-time-for-invalid-key",
|
|
||||||
Path: "/api/v1/services/invalid_key/response-times/7d/badge.svg",
|
|
||||||
ExpectedCode: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "chart-response-time-24h",
|
|
||||||
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "chart-response-time-7d",
|
|
||||||
Path: "/api/v1/services/core_frontend/response-times/7d/chart.svg",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "chart-response-time-with-invalid-duration",
|
|
||||||
Path: "/api/v1/services/core_backend/response-times/3d/chart.svg",
|
|
||||||
ExpectedCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "chart-response-time-for-invalid-key",
|
|
||||||
Path: "/api/v1/services/invalid_key/response-times/7d/chart.svg",
|
|
||||||
ExpectedCode: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "service-statuses",
|
|
||||||
Path: "/api/v1/services/statuses",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "service-statuses-gzip",
|
|
||||||
Path: "/api/v1/services/statuses",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
Gzip: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "service-statuses-pagination",
|
|
||||||
Path: "/api/v1/services/statuses?page=1&pageSize=20",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "service-status",
|
|
||||||
Path: "/api/v1/services/core_frontend/statuses",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "service-status-gzip",
|
|
||||||
Path: "/api/v1/services/core_frontend/statuses",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
Gzip: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "service-status-pagination",
|
|
||||||
Path: "/api/v1/services/core_frontend/statuses?page=1&pageSize=20",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "service-status-for-invalid-key",
|
|
||||||
Path: "/api/v1/services/invalid_key/statuses",
|
|
||||||
ExpectedCode: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "favicon",
|
|
||||||
Path: "/favicon.ico",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "frontend-home",
|
|
||||||
Path: "/",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "frontend-assets",
|
|
||||||
Path: "/js/app.js",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "frontend-service",
|
|
||||||
Path: "/services/core_frontend",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
|
||||||
request, _ := http.NewRequest("GET", scenario.Path, nil)
|
|
||||||
if scenario.Gzip {
|
|
||||||
request.Header.Set("Accept-Encoding", "gzip")
|
|
||||||
}
|
|
||||||
responseRecorder := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(responseRecorder, request)
|
|
||||||
if responseRecorder.Code != scenario.ExpectedCode {
|
|
||||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandle(t *testing.T) {
|
func TestHandle(t *testing.T) {
|
||||||
defer storage.Get().Clear()
|
|
||||||
defer cache.Clear()
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Web: &config.WebConfig{
|
Web: &web.Config{
|
||||||
Address: "0.0.0.0",
|
Address: "0.0.0.0",
|
||||||
Port: rand.Intn(65534),
|
Port: rand.Intn(65534),
|
||||||
},
|
},
|
||||||
Services: []*core.Service{
|
Endpoints: []*core.Endpoint{
|
||||||
{
|
{
|
||||||
Name: "frontend",
|
Name: "frontend",
|
||||||
Group: "core",
|
Group: "core",
|
||||||
@@ -287,9 +32,9 @@ func TestHandle(t *testing.T) {
|
|||||||
_ = os.Setenv("ROUTER_TEST", "true")
|
_ = os.Setenv("ROUTER_TEST", "true")
|
||||||
_ = os.Setenv("ENVIRONMENT", "dev")
|
_ = os.Setenv("ENVIRONMENT", "dev")
|
||||||
defer os.Clearenv()
|
defer os.Clearenv()
|
||||||
Handle(cfg.Security, cfg.Web, cfg.Metrics)
|
Handle(cfg)
|
||||||
defer Shutdown()
|
defer Shutdown()
|
||||||
request, _ := http.NewRequest("GET", "/health", nil)
|
request, _ := http.NewRequest("GET", "/health", http.NoBody)
|
||||||
responseRecorder := httptest.NewRecorder()
|
responseRecorder := httptest.NewRecorder()
|
||||||
server.Handler.ServeHTTP(responseRecorder, request)
|
server.Handler.ServeHTTP(responseRecorder, request)
|
||||||
if responseRecorder.Code != http.StatusOK {
|
if responseRecorder.Code != http.StatusOK {
|
||||||
@@ -308,71 +53,3 @@ func TestShutdown(t *testing.T) {
|
|||||||
t.Error("server should've been shut down")
|
t.Error("server should've been shut down")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceStatusesHandler(t *testing.T) {
|
|
||||||
defer storage.Get().Clear()
|
|
||||||
defer cache.Clear()
|
|
||||||
staticFolder = "../web/static"
|
|
||||||
firstResult := &testSuccessfulResult
|
|
||||||
secondResult := &testUnsuccessfulResult
|
|
||||||
storage.Get().Insert(&testService, firstResult)
|
|
||||||
storage.Get().Insert(&testService, secondResult)
|
|
||||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
|
||||||
firstResult.Timestamp = time.Time{}
|
|
||||||
secondResult.Timestamp = time.Time{}
|
|
||||||
router := CreateRouter(nil, false)
|
|
||||||
|
|
||||||
type Scenario struct {
|
|
||||||
Name string
|
|
||||||
Path string
|
|
||||||
ExpectedCode int
|
|
||||||
ExpectedBody string
|
|
||||||
}
|
|
||||||
scenarios := []Scenario{
|
|
||||||
{
|
|
||||||
Name: "no-pagination",
|
|
||||||
Path: "/api/v1/services/statuses",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "pagination-first-result",
|
|
||||||
Path: "/api/v1/services/statuses?page=1&pageSize=1",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "pagination-second-result",
|
|
||||||
Path: "/api/v1/services/statuses?page=2&pageSize=1",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "pagination-no-results",
|
|
||||||
Path: "/api/v1/services/statuses?page=5&pageSize=20",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[],"events":[]}]`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "invalid-pagination-should-fall-back-to-default",
|
|
||||||
Path: "/api/v1/services/statuses?page=INVALID&pageSize=INVALID",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
|
||||||
request, _ := http.NewRequest("GET", scenario.Path, nil)
|
|
||||||
responseRecorder := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(responseRecorder, request)
|
|
||||||
if responseRecorder.Code != scenario.ExpectedCode {
|
|
||||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
|
||||||
}
|
|
||||||
output := responseRecorder.Body.String()
|
|
||||||
if output != scenario.ExpectedBody {
|
|
||||||
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
// favIconHandler handles requests for /favicon.ico
|
|
||||||
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
|
|
||||||
}
|
|
||||||
324
controller/handler/badge.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/config"
|
||||||
|
"github.com/TwiN/gatus/v4/storage/store"
|
||||||
|
"github.com/TwiN/gatus/v4/storage/store/common"
|
||||||
|
"github.com/TwiN/gatus/v4/storage/store/common/paging"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
badgeColorHexAwesome = "#40cc11"
|
||||||
|
badgeColorHexGreat = "#94cc11"
|
||||||
|
badgeColorHexGood = "#ccd311"
|
||||||
|
badgeColorHexPassable = "#ccb311"
|
||||||
|
badgeColorHexBad = "#cc8111"
|
||||||
|
badgeColorHexVeryBad = "#c7130a"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
HealthStatusUp = "up"
|
||||||
|
HealthStatusDown = "down"
|
||||||
|
HealthStatusUnknown = "?"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
badgeColors = []string{badgeColorHexAwesome, badgeColorHexGreat, badgeColorHexGood, badgeColorHexPassable, badgeColorHexBad}
|
||||||
|
)
|
||||||
|
|
||||||
|
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
|
//
|
||||||
|
// Valid values for {duration}: 7d, 24h, 1h
|
||||||
|
func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
variables := mux.Vars(request)
|
||||||
|
duration := variables["duration"]
|
||||||
|
var from time.Time
|
||||||
|
switch duration {
|
||||||
|
case "7d":
|
||||||
|
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||||
|
case "24h":
|
||||||
|
from = time.Now().Add(-24 * time.Hour)
|
||||||
|
case "1h":
|
||||||
|
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||||
|
default:
|
||||||
|
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := variables["key"]
|
||||||
|
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if err == common.ErrEndpointNotFound {
|
||||||
|
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||||
|
} else if err == common.ErrInvalidTimeRange {
|
||||||
|
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
writer.Header().Set("Expires", "0")
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
|
//
|
||||||
|
// Valid values for {duration}: 7d, 24h, 1h
|
||||||
|
func ResponseTimeBadge(config *config.Config) http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
variables := mux.Vars(request)
|
||||||
|
duration := variables["duration"]
|
||||||
|
var from time.Time
|
||||||
|
switch duration {
|
||||||
|
case "7d":
|
||||||
|
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||||
|
case "24h":
|
||||||
|
from = time.Now().Add(-24 * time.Hour)
|
||||||
|
case "1h":
|
||||||
|
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
|
||||||
|
default:
|
||||||
|
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := variables["key"]
|
||||||
|
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if err == common.ErrEndpointNotFound {
|
||||||
|
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||||
|
} else if err == common.ErrInvalidTimeRange {
|
||||||
|
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
writer.Header().Set("Expires", "0")
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
|
func HealthBadge(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
variables := mux.Vars(request)
|
||||||
|
key := variables["key"]
|
||||||
|
pagingConfig := paging.NewEndpointStatusParams()
|
||||||
|
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||||
|
if err != nil {
|
||||||
|
if err == common.ErrEndpointNotFound {
|
||||||
|
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||||
|
} else if err == common.ErrInvalidTimeRange {
|
||||||
|
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
healthStatus := HealthStatusUnknown
|
||||||
|
if len(status.Results) > 0 {
|
||||||
|
if status.Results[0].Success {
|
||||||
|
healthStatus = HealthStatusUp
|
||||||
|
} else {
|
||||||
|
healthStatus = HealthStatusDown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
writer.Header().Set("Expires", "0")
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = writer.Write(generateHealthBadgeSVG(healthStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
||||||
|
var labelWidth, valueWidth, valueWidthAdjustment int
|
||||||
|
switch duration {
|
||||||
|
case "7d":
|
||||||
|
labelWidth = 65
|
||||||
|
case "24h":
|
||||||
|
labelWidth = 70
|
||||||
|
case "1h":
|
||||||
|
labelWidth = 65
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
color := getBadgeColorFromUptime(uptime)
|
||||||
|
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", uptime*100), "0"), ".") + "%"
|
||||||
|
if strings.Contains(sanitizedValue, ".") {
|
||||||
|
valueWidthAdjustment = -10
|
||||||
|
}
|
||||||
|
valueWidth = (len(sanitizedValue) * 11) + valueWidthAdjustment
|
||||||
|
width := labelWidth + valueWidth
|
||||||
|
labelX := labelWidth / 2
|
||||||
|
valueX := labelWidth + (valueWidth / 2)
|
||||||
|
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
|
||||||
|
<linearGradient id="b" x2="0" y2="100%%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="a">
|
||||||
|
<rect width="%d" height="20" rx="3" fill="#fff"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)">
|
||||||
|
<path fill="#555" d="M0 0h%dv20H0z"/>
|
||||||
|
<path fill="%s" d="M%d 0h%dv20H%dz"/>
|
||||||
|
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
||||||
|
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||||
|
uptime %s
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="14">
|
||||||
|
uptime %s
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||||
|
%s
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="14">
|
||||||
|
%s
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBadgeColorFromUptime(uptime float64) string {
|
||||||
|
if uptime >= 0.975 {
|
||||||
|
return badgeColorHexAwesome
|
||||||
|
} else if uptime >= 0.95 {
|
||||||
|
return badgeColorHexGreat
|
||||||
|
} else if uptime >= 0.9 {
|
||||||
|
return badgeColorHexGood
|
||||||
|
} else if uptime >= 0.8 {
|
||||||
|
return badgeColorHexPassable
|
||||||
|
} else if uptime >= 0.65 {
|
||||||
|
return badgeColorHexBad
|
||||||
|
}
|
||||||
|
return badgeColorHexVeryBad
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
|
||||||
|
var labelWidth, valueWidth int
|
||||||
|
switch duration {
|
||||||
|
case "7d":
|
||||||
|
labelWidth = 105
|
||||||
|
case "24h":
|
||||||
|
labelWidth = 110
|
||||||
|
case "1h":
|
||||||
|
labelWidth = 105
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
color := getBadgeColorFromResponseTime(averageResponseTime, key, cfg)
|
||||||
|
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
|
||||||
|
valueWidth = len(sanitizedValue) * 11
|
||||||
|
width := labelWidth + valueWidth
|
||||||
|
labelX := labelWidth / 2
|
||||||
|
valueX := labelWidth + (valueWidth / 2)
|
||||||
|
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
|
||||||
|
<linearGradient id="b" x2="0" y2="100%%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="a">
|
||||||
|
<rect width="%d" height="20" rx="3" fill="#fff"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)">
|
||||||
|
<path fill="#555" d="M0 0h%dv20H0z"/>
|
||||||
|
<path fill="%s" d="M%d 0h%dv20H%dz"/>
|
||||||
|
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
||||||
|
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||||
|
response time %s
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="14">
|
||||||
|
response time %s
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||||
|
%s
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="14">
|
||||||
|
%s
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
|
||||||
|
endpoint := cfg.GetEndpointByKey(key)
|
||||||
|
// the threshold config requires 5 values, so we can be sure it's set here
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
|
||||||
|
return badgeColors[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return badgeColorHexVeryBad
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateHealthBadgeSVG(healthStatus string) []byte {
|
||||||
|
var labelWidth, valueWidth int
|
||||||
|
switch healthStatus {
|
||||||
|
case HealthStatusUp:
|
||||||
|
valueWidth = 28
|
||||||
|
case HealthStatusDown:
|
||||||
|
valueWidth = 44
|
||||||
|
case HealthStatusUnknown:
|
||||||
|
valueWidth = 10
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
color := getBadgeColorFromHealth(healthStatus)
|
||||||
|
labelWidth = 48
|
||||||
|
|
||||||
|
width := labelWidth + valueWidth
|
||||||
|
labelX := labelWidth / 2
|
||||||
|
valueX := labelWidth + (valueWidth / 2)
|
||||||
|
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
|
||||||
|
<linearGradient id="b" x2="0" y2="100%%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="a">
|
||||||
|
<rect width="%d" height="20" rx="3" fill="#fff"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#a)">
|
||||||
|
<path fill="#555" d="M0 0h%dv20H0z"/>
|
||||||
|
<path fill="%s" d="M%d 0h%dv20H%dz"/>
|
||||||
|
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
||||||
|
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||||
|
health
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="14">
|
||||||
|
health
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||||
|
%s
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="14">
|
||||||
|
%s
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, labelX, valueX, healthStatus, valueX, healthStatus))
|
||||||
|
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBadgeColorFromHealth(healthStatus string) string {
|
||||||
|
if healthStatus == HealthStatusUp {
|
||||||
|
return badgeColorHexAwesome
|
||||||
|
} else if healthStatus == HealthStatusDown {
|
||||||
|
return badgeColorHexVeryBad
|
||||||
|
}
|
||||||
|
return badgeColorHexPassable
|
||||||
|
}
|
||||||