Compare commits
472 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea7bf2c194 | ||
|
|
db8535c3f3 | ||
|
|
fdd00e7851 | ||
|
|
2a94f76244 | ||
|
|
f2c5f5911c | ||
|
|
9d151fcdb4 | ||
|
|
4397dcb5fc | ||
|
|
739e6c75a6 | ||
|
|
dcfdfd423e | ||
|
|
28339684bf | ||
|
|
519500508a | ||
|
|
76a84031c2 | ||
|
|
b88d0b1c34 | ||
|
|
e0ab35e86a | ||
|
|
7efe7429dd | ||
|
|
4393a49900 | ||
|
|
241956b28c | ||
|
|
a4bc3c4dfe | ||
|
|
7cf2b427c9 | ||
|
|
dc9cddfd77 | ||
|
|
f93cebe715 | ||
|
|
f54c45e20e | ||
|
|
cacfbc0185 | ||
|
|
922638e071 | ||
|
|
979d467e36 | ||
|
|
ceb2c7884f | ||
|
|
ae750aa367 | ||
|
|
5aa83ee274 | ||
|
|
1e431c797a | ||
|
|
143a093e20 | ||
|
|
1eba430797 | ||
|
|
6299b630ce | ||
|
|
1fcc6c0cc0 | ||
|
|
408a46f2af | ||
|
|
3269e96f49 | ||
|
|
08742e4af3 | ||
|
|
2a623a59d3 | ||
|
|
3d1b4e566d | ||
|
|
6cbc59b0e8 | ||
|
|
1a7aeb5b35 | ||
|
|
228cd4d1fb | ||
|
|
bdad56e205 | ||
|
|
911902353f | ||
|
|
f76be6df92 | ||
|
|
dd6c4142fb | ||
|
|
1e82d2f07d | ||
|
|
3c246f0c69 | ||
|
|
76111ee133 | ||
|
|
d3a82244a1 | ||
|
|
807599c05e | ||
|
|
de7256e671 | ||
|
|
c6515c4b1c | ||
|
|
522b958d0f | ||
|
|
16366e169e | ||
|
|
ea3ae52f1e | ||
|
|
5a16151bba | ||
|
|
802ad7ff8f | ||
|
|
619b69f480 | ||
|
|
87e029f555 | ||
|
|
71c4d3ade1 | ||
|
|
315f9b7792 | ||
|
|
bde30b2efb | ||
|
|
88cb92745b | ||
|
|
744d63abac | ||
|
|
c1cdf50851 | ||
|
|
2bd268670f | ||
|
|
e88bfa8518 | ||
|
|
0fa3c5d114 | ||
|
|
0903d28b56 | ||
|
|
b50f3f3646 | ||
|
|
1b0dfdd09d | ||
|
|
6d3468d81a | ||
|
|
200d007eca | ||
|
|
24c3a84db9 | ||
|
|
0402bdb774 | ||
|
|
c7af44bcb0 | ||
|
|
494a8594cc | ||
|
|
81dd84e5f2 | ||
|
|
aa7f8131cd | ||
|
|
4dea597726 | ||
|
|
8df77b09ed | ||
|
|
d2b274e609 | ||
|
|
15c81f93d2 | ||
|
|
05565e3d0a | ||
|
|
8fbfba2163 | ||
|
|
0e9df7f00f | ||
|
|
7d570ce148 | ||
|
|
67941865db | ||
|
|
5c5a954b68 | ||
|
|
5f69351b6b | ||
|
|
34313bec7e | ||
|
|
640e455d33 | ||
|
|
2be6a1e5f3 | ||
|
|
225e6ac7ae | ||
|
|
87d9722621 | ||
|
|
fd17dcd204 | ||
|
|
2f6b8f23f7 | ||
|
|
0a37a61619 | ||
|
|
70f9f8738c | ||
|
|
a725e7e770 | ||
|
|
fc29c45e5f | ||
|
|
6a8c308af7 | ||
|
|
0e33556775 | ||
|
|
6bb65f4eec | ||
|
|
9142ff837c | ||
|
|
11eb7fb02e | ||
|
|
271b836160 | ||
|
|
c42f3ef787 | ||
|
|
c7a774b213 | ||
|
|
36207490b2 | ||
|
|
5eebe6d9cc | ||
|
|
ecda4a9987 | ||
|
|
162dd9bc7c | ||
|
|
60b0f3fa29 | ||
|
|
0cf7621162 | ||
|
|
8d27864aaa | ||
|
|
3bf880a199 | ||
|
|
719f684982 | ||
|
|
a3df822df3 | ||
|
|
d62a6c5054 | ||
|
|
fcf8b5d86f | ||
|
|
5bbd240dd9 | ||
|
|
7e163c3fcf | ||
|
|
1dd0ecf10b | ||
|
|
3350e81443 | ||
|
|
c3e1835dd6 | ||
|
|
2a45a151da | ||
|
|
74cde8ae8d | ||
|
|
669877baf4 | ||
|
|
447e140479 | ||
|
|
6908199716 | ||
|
|
b12f652553 | ||
|
|
83edca6e80 | ||
|
|
636688b43e | ||
|
|
4fdb55d632 | ||
|
|
a05daeda2e | ||
|
|
0bd0c1fd15 | ||
|
|
eb3ca71c72 | ||
|
|
37325cd78a | ||
|
|
f6e7e346b6 | ||
|
|
b5e742acde | ||
|
|
685351a025 | ||
|
|
ee8e0c4b40 | ||
|
|
fb94eea914 | ||
|
|
a69ccfdb08 | ||
|
|
018f723e78 | ||
|
|
038c8c8d8e | ||
|
|
f8f61deb2c | ||
|
|
32a15decfd | ||
|
|
0dba6e8674 | ||
|
|
0c92534432 | ||
|
|
6ab8899dc6 | ||
|
|
819abf4263 | ||
|
|
6950a080df | ||
|
|
7d6923730e | ||
|
|
542da61215 | ||
|
|
45fe7beb6d | ||
|
|
26611b7793 | ||
|
|
a29cf158dd | ||
|
|
9d14e3011b | ||
|
|
d13998d13d | ||
|
|
f6a39f6df0 | ||
|
|
9e2006910d | ||
|
|
6e4b88dc6e | ||
|
|
277e805dbb | ||
|
|
941c10ca45 | ||
|
|
21f62f362f | ||
|
|
d75180c341 | ||
|
|
a82b883276 | ||
|
|
24e207c0c6 | ||
|
|
90bb8f7b5f | ||
|
|
0db92f46da | ||
|
|
0ffa03f42d | ||
|
|
e61a42220c | ||
|
|
78dccc90e1 | ||
|
|
6bdd3c94fe | ||
|
|
4225d22369 | ||
|
|
3059e3e028 | ||
|
|
87740e74a6 | ||
|
|
8e14302765 | ||
|
|
844f417ea1 | ||
|
|
2f7f782f11 | ||
|
|
37bea336ca | ||
|
|
616a654b27 | ||
|
|
a1c8422c2f | ||
|
|
947173bf71 | ||
|
|
a81a83e2d4 | ||
|
|
4599fe4da7 | ||
|
|
19e90cdf31 | ||
|
|
ecc0636a59 | ||
|
|
27502acd10 | ||
|
|
51255e33ea | ||
|
|
be0962112e | ||
|
|
dfcea93080 | ||
|
|
a5f135c675 | ||
|
|
9acace7d37 | ||
|
|
184c7f23ad | ||
|
|
5ce890bbff | ||
|
|
b0bec5ff94 | ||
|
|
e503dd3861 | ||
|
|
f2d51f3e50 | ||
|
|
a1a2fba326 | ||
|
|
fdd51869a3 | ||
|
|
f6a621da28 | ||
|
|
2346a6ee4f | ||
|
|
741109f25d | ||
|
|
d058d7a54b | ||
|
|
7dccf5f08c | ||
|
|
9e46e3972d | ||
|
|
9fc8374a4d | ||
|
|
1aeb045703 | ||
|
|
cdec353744 | ||
|
|
080563bd4f | ||
|
|
bcb565ba37 | ||
|
|
2327854641 | ||
|
|
79eacc5e50 | ||
|
|
048a1d4a88 | ||
|
|
c09ee0b82f | ||
|
|
7908eea2df | ||
|
|
f8140e0d96 | ||
|
|
4f569b7a0e | ||
|
|
e9f46c58f8 | ||
|
|
502e159dca | ||
|
|
cdbf5902c7 | ||
|
|
c7f80f1301 | ||
|
|
eb4e22e76b | ||
|
|
f37a0ef2d7 | ||
|
|
114b78c75c | ||
|
|
d24ff5bd07 | ||
|
|
c172e733be | ||
|
|
f1ce83c211 | ||
|
|
64f4dac705 | ||
|
|
861c443842 | ||
|
|
b801cc5801 | ||
|
|
f1711b5c0b | ||
|
|
0ebd6c7a67 | ||
|
|
967124eb43 | ||
|
|
fa47a199e5 | ||
|
|
1f84f2afa0 | ||
|
|
ed3683cb32 | ||
|
|
6e92c0eb40 | ||
|
|
cd927f630b | ||
|
|
c6c9bc8fa5 | ||
|
|
a3facc3887 | ||
|
|
991d7e876d | ||
|
|
3b7fb083ca | ||
|
|
ebdf5bde49 | ||
|
|
d4983733f5 | ||
|
|
fed021826a | ||
|
|
8f9eca51c0 | ||
|
|
e13730f119 | ||
|
|
22d74a5ea8 | ||
|
|
fe4d9821f3 | ||
|
|
d01a5d418b | ||
|
|
34f8cd1eca | ||
|
|
d101c17136 | ||
|
|
ade3d05983 | ||
|
|
fbab0ef7ca | ||
|
|
9121ec1cc8 | ||
|
|
6ddf1258e5 | ||
|
|
490610ccfd | ||
|
|
0eb6958085 | ||
|
|
d20a41c7a7 | ||
|
|
4c18e0d602 | ||
|
|
da24b7e8ac | ||
|
|
c619066e25 | ||
|
|
3688dd6e6f | ||
|
|
fc778300be | ||
|
|
df560ad872 | ||
|
|
de9c366777 | ||
|
|
6a5fec2c55 | ||
|
|
01d2ed3f02 | ||
|
|
92b85ee1ab | ||
|
|
a789deb8c2 | ||
|
|
e5a94979dd | ||
|
|
3c0ea72a5c | ||
|
|
d17e893131 | ||
|
|
7ea34ec8a8 | ||
|
|
f6b99f34db | ||
|
|
37495ac3f3 | ||
|
|
557f696f88 | ||
|
|
c86492dbfd | ||
|
|
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 |
@@ -4,4 +4,5 @@ Dockerfile
|
|||||||
.idea
|
.idea
|
||||||
.git
|
.git
|
||||||
web/app
|
web/app
|
||||||
*.db
|
*.db
|
||||||
|
testdata
|
||||||
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)
|
||||||
|
```
|
||||||
@@ -2,15 +2,18 @@ metrics: true
|
|||||||
endpoints:
|
endpoints:
|
||||||
- name: website
|
- name: website
|
||||||
url: https://twin.sh/health
|
url: https://twin.sh/health
|
||||||
interval: 30s
|
interval: 5m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: example
|
||||||
|
url: https://example.com/
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
- name: github
|
- name: github
|
||||||
url: https://api.github.com/healthz
|
url: https://api.github.com/healthz
|
||||||
interval: 5m
|
interval: 5m
|
||||||
conditions:
|
|
||||||
- "[STATUS] == 200"
|
|
||||||
- name: example
|
|
||||||
url: https://example.com/
|
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
networks:
|
networks:
|
||||||
- metrics
|
- metrics
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
{
|
|
||||||
"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": 2,
|
|
||||||
"links": [],
|
|
||||||
"panels": [
|
|
||||||
{
|
|
||||||
"aliasColors": {},
|
|
||||||
"bars": false,
|
|
||||||
"dashLength": 10,
|
|
||||||
"dashes": false,
|
|
||||||
"datasource": null,
|
|
||||||
"fill": 1,
|
|
||||||
"fillGradient": 0,
|
|
||||||
"gridPos": {
|
|
||||||
"h": 14,
|
|
||||||
"w": 12,
|
|
||||||
"x": 0,
|
|
||||||
"y": 0
|
|
||||||
},
|
|
||||||
"id": 2,
|
|
||||||
"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_tasks[30s])) by (endpoint)",
|
|
||||||
"interval": "30s",
|
|
||||||
"legendFormat": "{{endpoint}}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"thresholds": [],
|
|
||||||
"timeFrom": null,
|
|
||||||
"timeRegions": [],
|
|
||||||
"timeShift": null,
|
|
||||||
"title": "All tasks executed over 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,
|
|
||||||
"dashLength": 10,
|
|
||||||
"dashes": false,
|
|
||||||
"datasource": null,
|
|
||||||
"fill": 1,
|
|
||||||
"fillGradient": 0,
|
|
||||||
"gridPos": {
|
|
||||||
"h": 7,
|
|
||||||
"w": 12,
|
|
||||||
"x": 12,
|
|
||||||
"y": 0
|
|
||||||
},
|
|
||||||
"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_tasks{success=\"false\"}[30s])) by (endpoint)",
|
|
||||||
"interval": "30s",
|
|
||||||
"legendFormat": "{{endpoint}}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"thresholds": [],
|
|
||||||
"timeFrom": null,
|
|
||||||
"timeRegions": [],
|
|
||||||
"timeShift": null,
|
|
||||||
"title": "Unsuccessful tasks",
|
|
||||||
"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,
|
|
||||||
"fill": 1,
|
|
||||||
"fillGradient": 0,
|
|
||||||
"gridPos": {
|
|
||||||
"h": 7,
|
|
||||||
"w": 12,
|
|
||||||
"x": 12,
|
|
||||||
"y": 7
|
|
||||||
},
|
|
||||||
"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_tasks{success=\"true\"}[30s])) by (endpoint)",
|
|
||||||
"instant": false,
|
|
||||||
"interval": "30s",
|
|
||||||
"legendFormat": "{{endpoint}}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"thresholds": [],
|
|
||||||
"timeFrom": null,
|
|
||||||
"timeRegions": [],
|
|
||||||
"timeShift": null,
|
|
||||||
"title": "Successful tasks",
|
|
||||||
"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": "10s",
|
|
||||||
"schemaVersion": 20,
|
|
||||||
"style": "dark",
|
|
||||||
"tags": [],
|
|
||||||
"templating": {
|
|
||||||
"list": []
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"from": "now-30m",
|
|
||||||
"to": "now"
|
|
||||||
},
|
|
||||||
"timepicker": {},
|
|
||||||
"timezone": "",
|
|
||||||
"title": "Gatus",
|
|
||||||
"uid": "KPI7Qj1Wk",
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
@@ -7,10 +7,9 @@ endpoints:
|
|||||||
- name: example
|
- name: example
|
||||||
url: https://example.org
|
url: https://example.org
|
||||||
interval: 1m
|
interval: 1m
|
||||||
alerts:
|
|
||||||
- type: mattermost
|
|
||||||
enabled: true
|
|
||||||
description: "health check failed 3 times in a row"
|
|
||||||
send-on-resolved: true
|
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
|
alerts:
|
||||||
|
- type: mattermost
|
||||||
|
description: "health check failed 3 times in a row"
|
||||||
|
send-on-resolved: true
|
||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
endpoints:
|
||||||
|
- name: check-if-api-is-healthy
|
||||||
|
group: backend
|
||||||
|
url: "https://twin.sh/health"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[BODY].status == UP"
|
||||||
|
- "[RESPONSE_TIME] < 1000"
|
||||||
|
|
||||||
|
- name: check-if-website-is-pingable
|
||||||
|
url: "icmp://example.org"
|
||||||
|
interval: 1m
|
||||||
|
conditions:
|
||||||
|
- "[CONNECTED] == true"
|
||||||
|
|
||||||
|
- name: check-domain-expiration
|
||||||
|
url: "https://example.org"
|
||||||
|
interval: 6h
|
||||||
|
conditions:
|
||||||
|
- "[DOMAIN_EXPIRATION] > 720h"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
endpoints:
|
||||||
|
- name: make-sure-html-rendering-works
|
||||||
|
group: frontend
|
||||||
|
url: "https://example.org"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[BODY] == pat(*<h1>Example Domain</h1>*)" # Check for header in HTML page
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
metrics: true
|
||||||
|
debug: false
|
||||||
|
ui:
|
||||||
|
header: Example Company
|
||||||
|
link: https://example.org
|
||||||
|
buttons:
|
||||||
|
- name: "Home"
|
||||||
|
link: "https://example.org"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
gatus:
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- GATUS_CONFIG_PATH=/config
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
storage:
|
storage:
|
||||||
type: postgres
|
type: postgres
|
||||||
path: "postgres://username:password@postgres:5432/gatus?sslmode=disable"
|
path: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: back-end
|
- name: back-end
|
||||||
@@ -32,7 +32,7 @@ endpoints:
|
|||||||
query-name: "example.com"
|
query-name: "example.com"
|
||||||
query-type: "A"
|
query-type: "A"
|
||||||
conditions:
|
conditions:
|
||||||
- "[BODY] == 93.184.216.34"
|
- "[BODY] == 93.184.215.14"
|
||||||
- "[DNS_RCODE] == NOERROR"
|
- "[DNS_RCODE] == NOERROR"
|
||||||
|
|
||||||
- name: icmp-ping
|
- name: icmp-ping
|
||||||
@@ -18,12 +18,16 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=username
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
- POSTGRES_DB=gatus
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
web:
|
web:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ endpoints:
|
|||||||
query-name: "example.com"
|
query-name: "example.com"
|
||||||
query-type: "A"
|
query-type: "A"
|
||||||
conditions:
|
conditions:
|
||||||
- "[BODY] == 93.184.216.34"
|
- "[BODY] == 93.184.215.14"
|
||||||
- "[DNS_RCODE] == NOERROR"
|
- "[DNS_RCODE] == NOERROR"
|
||||||
|
|
||||||
- name: icmp-ping
|
- name: icmp-ping
|
||||||
@@ -5,5 +5,5 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
- ./data:/data/
|
- ./data:/data/
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ spec:
|
|||||||
app: gatus
|
app: gatus
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
|
||||||
app: gatus
|
|
||||||
name: gatus
|
name: gatus
|
||||||
namespace: kube-system
|
namespace: kube-system
|
||||||
|
labels:
|
||||||
|
app: gatus
|
||||||
spec:
|
spec:
|
||||||
serviceAccountName: gatus
|
serviceAccountName: gatus
|
||||||
terminationGracePeriodSeconds: 5
|
terminationGracePeriodSeconds: 5
|
||||||
@@ -76,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
|
||||||
|
|||||||
BIN
.github/assets/dashboard-dark.png
vendored
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 90 KiB |
2
.github/assets/gatus-diagram.drawio
vendored
@@ -1 +1 @@
|
|||||||
<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>
|
<mxfile host="app.diagrams.net" modified="2022-12-07T04:00:31.242Z" agent="5.0 (Windows)" etag="4-CttOJPoGYGt_6RMEMf" version="20.5.3" type="device"><diagram id="oCf8YAkR0GE5Fy88uv5t" name="Page-1">7Vxbc6M2FP41frQHxDWPuW13Z9JpZtKddPdNAQWrBcQKObH76ytsyVxkHOxgC6bOS+BICPl856ZzJCbWbbL8jcJs/jsJUTwBRricWHcTAEzDBfxfQVkJiu9ZG0pEcShoJeEJ/4vko4K6wCHKax0ZITHDWZ0YkDRFAavRIKXkvd7tlcT1t2YwQgrhKYCxSn3GIZsLKjCMsuErwtFcvvoKiJYEyt6CkM9hSN4rJOt+Yt1SQtjmKlneorhgn2TM5rkvLa3bmVGUsi4PfMHmwwP9av/5bDqPP9Obbw/4j6kn5sZW8hejkDNA3BLK5iQiKYzvS+oNJYs0RMWoBr8r+zwQknGiyYl/I8ZWAk24YIST5iyJRSufMF39JZ5f3/wobmaOvL1bVhvvVuLulaRMDGr6/H4z92LCrSwRpJwsaID28EHKFqQRYnv6uVvguMwjkiA+P/4cRTFk+K0+DyhkL9r2K9HhFwKgA8AS477BeCHe9D1HVEGQC1pWXC6S+DpghHJOvSHKMJfqB/iC4keSY4ZJyru8EMZIUulwHeOoaGAFlFXMyILFOEW3Wz0ztgAUz6LlfghUlokHHKEdwkA4UvHfS22zBGle0TPXOBGPry4KUQp6B4XwdSoEsBSNmAA3ZoI1NRzdXwsiG6b5mmnXvINpZ8s152Q7v4qK/zgvWLnMSM6RBcbLSo7MZ7oZfNNvp7ys9ayOMRSaFXAoEN2hcgkOw404IT49+LIer0A5Izhla9Y5NxPnrhX3HXoo3KIYrPRFVYloV4NWpZ0aM9Mz7c1YnaEWwz0WP6ccywQ1AzCV93IE8vqacwlsisp2UsdLj/t54fFbhEcOlGcwlbTv3yoSVG3oKlh1M/M+xww9ZXCtxe88/KqL21pExLNmq3x0t9Om/bGhNsE5LbWpukPKB8boDRWqG0IGi4iPcv+m1aSXVvxHte38Jt3vaNJNY7ckHKbo15TCVaWDsGKtdsB26wJmXzXC2cP684vNDHo1Gb4icjwgYpTEMaJ5DwpbAb5vBbaNoSkwcMcaa9k9Lz6MjpoJHK3Rlvd5hwn2RVs4yWKUFPHRoCOuEv0+Iy7QsrQsQy4LeE4jVuonAvMbozZGOF0EJgW/IlFPfMFa5GRGZUstX7ctNR2Fk6i4hgypfun/Gf7IhcXH8Y+t08pKbRyfV9SGl6UVr0vG6FC8Pru++FwUo/qcjAfvOGfrECTVHV34a/FgUGRo96ZZjw42zI/zO7ZfDzZAL7HGtJ7tOV+yR76pAvszZME8JNGwY41m4mWbMdcWa1haLN4pLZfV0XJZWrPdpprtvo4Lo5MOXISb4fJWXPWFy7bCyvs0LFNUw+Vl0xzo56X8DRVeMoqjaFdGbOS+dD80ltWw1KYxMwzbND3X9gzHd+zDvSUfoPZn197gnst7WpYOkz/AIBc4HV0FaImwzuMqLPuC12F4WS26fSa8nAteB+KldxGpptt6qx0XGVB0eE3YPCw22ZvE7u7zXHtomVBwdTpoEpQQ2rkscXTcCPrBxm9i43XEBpxs5ajmXnrDJiM5i2iR7R4nOralHR01kO8NnfxXzBk8WmwM3dg4ow3oSkh6CRCsrrtYbK1VHGe0AZ02vLTubXBGuxdFG15at8I7oz24oA0vTyteo61qa8NLa63BGW1VWxteWhN+cjl9wasrXo7WBJJrXvA6EC+tCVp5kPWCV2e8gFa8Rluw0oaX1l10lnqy4pGSNxzuON96dM5771G543Pe+kvwdg/nIC878z8E3vHcOvD2yQBV96cEi5ztOMo2tFRpV104WabUVktzIc4DQsPB867rzr7T8U49EosSiOPBc65r1fF0nFPPRkWERDEK5pANnn1m17OAp+Of6v8TyCheDp93tnbeqRVvzjvuWBOSj0D2tFelpdmt8C9lr6vBcw7or0qqFePig0g0XLARsE97wd0BCvvyGAb/DJ91rnbWqSsexILZbDZ83l3p5p2ruRZz/InQnnMjrlhlDfurX57qnpqLe/nZPLl2t3Zsj6KE8dVv5+1RZ16571W2o4+5uS01NDF8ccrtCtS0c9rPR42m9X36Z/yokae5sDCY496dlVtrndVT/VipqXpV8jQfFvP2e0tjBky/oTzW51RSDnO2wzKumrx7QsGC4j6D4ubZMqMVqu6hCvDqoYrbdWl7RGKb35afPt1wvvyErHX/Hw==</diagram></mxfile>
|
||||||
BIN
.github/assets/gatus-diagram.jpg
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
.github/assets/gatus-diagram.png
vendored
|
Before Width: | Height: | Size: 20 KiB |
BIN
.github/assets/github-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
.github/assets/gitlab-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
.github/assets/gotify-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.github/assets/grafana-dashboard.png
vendored
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
.github/assets/jetbrains-space-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 31 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
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 17 KiB |
BIN
.github/assets/mattermost-alerts.png
vendored
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 48 KiB |
5
.github/codecov.yml
vendored
@@ -5,3 +5,8 @@ ignore:
|
|||||||
coverage:
|
coverage:
|
||||||
status:
|
status:
|
||||||
patch: off
|
patch: off
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
target: 75%
|
||||||
|
threshold: null
|
||||||
|
|
||||||
|
|||||||
13
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
labels: ["dependencies"]
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/"
|
||||||
|
open-pull-requests-limit: 1
|
||||||
|
labels: ["dependencies"]
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
30
.github/workflows/benchmark.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: benchmark
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [publish-latest]
|
||||||
|
branches: [master]
|
||||||
|
types: [completed]
|
||||||
|
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@v5
|
||||||
|
with:
|
||||||
|
go-version: 1.19
|
||||||
|
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||||
|
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Benchmark
|
||||||
|
run: go test -bench=. ./storage/store
|
||||||
26
.github/workflows/publish-experimental.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: publish-experimental
|
||||||
|
on: [workflow_dispatch]
|
||||||
|
jobs:
|
||||||
|
publish-experimental:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Get image repository
|
||||||
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.IMAGE_REPOSITORY }}:experimental
|
||||||
38
.github/workflows/publish-latest-to-ghcr.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: publish-latest-to-ghcr
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [test]
|
||||||
|
branches: [master]
|
||||||
|
types: [completed]
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
jobs:
|
||||||
|
publish-latest-to-ghcr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Get image repository
|
||||||
|
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
- name: Login to Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
||||||
32
.github/workflows/publish-latest.yml
vendored
@@ -1,34 +1,34 @@
|
|||||||
name: publish-latest
|
name: publish-latest
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["build"]
|
workflows: [test]
|
||||||
branches: [master]
|
branches: [master]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
publish-latest:
|
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') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v2
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | 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
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
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@v3
|
||||||
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@v5
|
||||||
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: ${{ env.IMAGE_REPOSITORY }}:latest
|
||||||
${{ env.IMAGE_REPOSITORY }}:latest
|
|
||||||
|
|||||||
37
.github/workflows/publish-release-to-ghcr.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: publish-release-to-ghcr
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
jobs:
|
||||||
|
publish-release-to-ghcr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Get image repository
|
||||||
|
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
- name: Get the release
|
||||||
|
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||||
|
- name: Login to Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
||||||
|
${{ env.IMAGE_REPOSITORY }}:stable
|
||||||
|
${{ env.IMAGE_REPOSITORY }}:latest
|
||||||
23
.github/workflows/publish-release.yml
vendored
@@ -4,30 +4,31 @@ 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@v4
|
||||||
uses: actions/checkout@v2
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | 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
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
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@v3
|
||||||
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@v5
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v6,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 }}:${{ env.RELEASE }}
|
||||||
|
${{ env.IMAGE_REPOSITORY }}:stable
|
||||||
|
${{ env.IMAGE_REPOSITORY }}:latest
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
name: build
|
name: test
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '*.md'
|
- '*.md'
|
||||||
|
- '.examples/**'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '*.md'
|
- '*.md'
|
||||||
|
- '.github/**'
|
||||||
|
- '.examples/**'
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
name: Build
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Go
|
- uses: actions/setup-go@v5
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
with:
|
||||||
go-version: 1.17
|
go-version: 1.21
|
||||||
- name: Check out code into the Go module directory
|
- uses: actions/checkout@v4
|
||||||
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
|
||||||
- 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" 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 ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||||
- name: Codecov
|
- name: Codecov
|
||||||
uses: codecov/codecov-action@v2.1.0
|
uses: codecov/codecov-action@v4.4.0
|
||||||
with:
|
with:
|
||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
22
.gitignore
vendored
@@ -1,8 +1,20 @@
|
|||||||
|
# IDE
|
||||||
|
*.iml
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# JS
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Go
|
||||||
|
/vendor
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
gatus
|
gatus
|
||||||
db.db
|
config/config.yml
|
||||||
config/config.yml
|
|
||||||
db.db-shm
|
|
||||||
db.db-wal
|
|
||||||
memory.db
|
|
||||||
@@ -3,7 +3,8 @@ FROM golang:alpine as builder
|
|||||||
RUN apk --update add ca-certificates
|
RUN apk --update add ca-certificates
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus .
|
RUN go mod tidy
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
||||||
|
|
||||||
# Run Tests inside docker image if you don't have a configured go environment
|
# Run Tests inside docker image if you don't have a configured go environment
|
||||||
#RUN apk update && apk add --virtual build-dependencies build-base gcc
|
#RUN apk update && apk add --virtual build-dependencies build-base gcc
|
||||||
@@ -13,7 +14,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}
|
||||||
|
|||||||
2
LICENSE
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright 2021 TwiN
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
10
Makefile
@@ -1,16 +1,20 @@
|
|||||||
BINARY=gatus
|
BINARY=gatus
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
install:
|
install:
|
||||||
go build -mod vendor -o $(BINARY) .
|
go build -v -o $(BINARY) .
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
run:
|
run:
|
||||||
GATUS_CONFIG_FILE=./config.yaml ./$(BINARY)
|
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm $(BINARY)
|
rm $(BINARY)
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
sudo go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
|
go test ./... -cover
|
||||||
|
|
||||||
|
|
||||||
##########
|
##########
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
package alert
|
package alert
|
||||||
|
|
||||||
// Alert is a core.Endpoint's alert configuration
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"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 endpoint.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 the alert is enabled
|
// Enabled defines whether the alert is enabled
|
||||||
//
|
//
|
||||||
|
// Use Alert.IsEnabled() to retrieve the value of this field.
|
||||||
|
//
|
||||||
// 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,omitempty"`
|
Enabled *bool `yaml:"enabled,omitempty"`
|
||||||
@@ -14,6 +29,9 @@ type Alert struct {
|
|||||||
// 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"`
|
||||||
|
|
||||||
|
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
|
||||||
|
SuccessThreshold int `yaml:"success-threshold"`
|
||||||
|
|
||||||
// Description of the alert. Will be included in the alert sent.
|
// Description of the alert. Will be included in the alert sent.
|
||||||
//
|
//
|
||||||
// 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
|
||||||
@@ -26,9 +44,6 @@ type Alert struct {
|
|||||||
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
||||||
SendOnResolved *bool `yaml:"send-on-resolved"`
|
SendOnResolved *bool `yaml:"send-on-resolved"`
|
||||||
|
|
||||||
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
|
|
||||||
SuccessThreshold int `yaml:"success-threshold"`
|
|
||||||
|
|
||||||
// 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 `yaml:"-"`
|
ResolveKey string `yaml:"-"`
|
||||||
@@ -44,8 +59,22 @@ type Alert struct {
|
|||||||
Triggered bool `yaml:"-"`
|
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
|
||||||
func (alert Alert) GetDescription() string {
|
func (alert *Alert) GetDescription() string {
|
||||||
if alert.Description == nil {
|
if alert.Description == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -53,17 +82,32 @@ func (alert Alert) GetDescription() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsEnabled returns whether an alert is enabled or not
|
// IsEnabled returns whether an alert is enabled or not
|
||||||
func (alert Alert) IsEnabled() bool {
|
// Returns true if not set
|
||||||
|
func (alert *Alert) IsEnabled() bool {
|
||||||
if alert.Enabled == nil {
|
if alert.Enabled == nil {
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
return *alert.Enabled
|
return *alert.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSendingOnResolved returns whether an alert is sending on resolve or not
|
// IsSendingOnResolved returns whether an alert is sending on resolve or not
|
||||||
func (alert Alert) IsSendingOnResolved() bool {
|
func (alert *Alert) IsSendingOnResolved() bool {
|
||||||
if alert.SendOnResolved == nil {
|
if alert.SendOnResolved == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return *alert.SendOnResolved
|
return *alert.SendOnResolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checksum returns a checksum of the alert
|
||||||
|
// Used to determine which persisted triggered alert should be deleted on application start
|
||||||
|
func (alert *Alert) Checksum() string {
|
||||||
|
hash := sha256.New()
|
||||||
|
hash.Write([]byte(string(alert.Type) + "_" +
|
||||||
|
strconv.FormatBool(alert.IsEnabled()) + "_" +
|
||||||
|
strconv.FormatBool(alert.IsSendingOnResolved()) + "_" +
|
||||||
|
strconv.Itoa(alert.SuccessThreshold) + "_" +
|
||||||
|
strconv.Itoa(alert.FailureThreshold) + "_" +
|
||||||
|
alert.GetDescription()),
|
||||||
|
)
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +1,192 @@
|
|||||||
package alert
|
package alert
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"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(); !errors.Is(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() {
|
||||||
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to nil")
|
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
|
||||||
}
|
}
|
||||||
if value := false; (Alert{Enabled: &value}).IsEnabled() {
|
if value := false; (&Alert{Enabled: &value}).IsEnabled() {
|
||||||
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
|
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
|
||||||
}
|
}
|
||||||
if value := true; !(Alert{Enabled: &value}).IsEnabled() {
|
if value := true; !(&Alert{Enabled: &value}).IsEnabled() {
|
||||||
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
|
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlert_GetDescription(t *testing.T) {
|
func TestAlert_GetDescription(t *testing.T) {
|
||||||
if (Alert{Description: nil}).GetDescription() != "" {
|
if (&Alert{Description: nil}).GetDescription() != "" {
|
||||||
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
|
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
|
||||||
}
|
}
|
||||||
if value := "description"; (Alert{Description: &value}).GetDescription() != value {
|
if value := "description"; (&Alert{Description: &value}).GetDescription() != value {
|
||||||
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
|
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlert_IsSendingOnResolved(t *testing.T) {
|
func TestAlert_IsSendingOnResolved(t *testing.T) {
|
||||||
if (Alert{SendOnResolved: nil}).IsSendingOnResolved() {
|
if (&Alert{SendOnResolved: nil}).IsSendingOnResolved() {
|
||||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
|
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
|
||||||
}
|
}
|
||||||
if value := false; (Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
if value := false; (&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
|
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
|
||||||
}
|
}
|
||||||
if value := true; !(Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
if value := true; !(&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||||
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
|
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlert_Checksum(t *testing.T) {
|
||||||
|
description1, description2 := "a", "b"
|
||||||
|
yes, no := true, false
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
alert Alert
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "barebone",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
},
|
||||||
|
expected: "fed0580e44ed5701dbba73afa1f14b2c53ca5a7b8067a860441c212916057fe3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-1",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Description: &description1,
|
||||||
|
},
|
||||||
|
expected: "005f407ebe506e74a4aeb46f74c28b376debead7011e1b085da3840f72ba9707",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-enabled-false",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Enabled: &no,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "837945c2b4cd5e961db3e63e10c348d4f1c3446ba68cf5a48e35a1ae22cf0c22",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-enabled-true",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Enabled: &yes, // it defaults to true if not set, but just to make sure
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-enabled-true-and-send-on-resolved-true",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Enabled: &yes,
|
||||||
|
SendOnResolved: &yes,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "bf1436995a880eb4a352c74c5dfee1f1b5ff6b9fc55aef9bf411b3631adfd80c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-failure-threshold-7",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeSlack,
|
||||||
|
FailureThreshold: 7,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "8bd479e18bda393d4c924f5a0d962e825002168dedaa88b445e435db7bacffd3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-failure-threshold-9",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeSlack,
|
||||||
|
FailureThreshold: 9,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "5abdfce5236e344996d264d526e769c07cb0d3d329a999769a1ff84b157ca6f1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-success-threshold-5",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeSlack,
|
||||||
|
SuccessThreshold: 7,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "c0000e73626b80e212cfc24830de7094568f648e37f3e16f9e68c7f8ef75c34c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-success-threshold-1",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeSlack,
|
||||||
|
SuccessThreshold: 1,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "5c28963b3a76104cfa4a0d79c89dd29ec596c8cfa4b1af210ec83d6d41587b5f",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
scenario.alert.ValidateAndSetDefaults()
|
||||||
|
if checksum := scenario.alert.Checksum(); checksum != scenario.expected {
|
||||||
|
t.Errorf("expected checksum %v, got %v", scenario.expected, checksum)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ package alert
|
|||||||
type Type string
|
type Type string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// TypeAWSSES is the Type for the awsses alerting provider
|
||||||
|
TypeAWSSES Type = "aws-ses"
|
||||||
|
|
||||||
// TypeCustom is the Type for the custom alerting provider
|
// TypeCustom is the Type for the custom alerting provider
|
||||||
TypeCustom Type = "custom"
|
TypeCustom Type = "custom"
|
||||||
|
|
||||||
@@ -14,15 +17,42 @@ const (
|
|||||||
// TypeEmail is the Type for the email alerting provider
|
// TypeEmail is the Type for the email alerting provider
|
||||||
TypeEmail Type = "email"
|
TypeEmail Type = "email"
|
||||||
|
|
||||||
|
// TypeGitHub is the Type for the github alerting provider
|
||||||
|
TypeGitHub Type = "github"
|
||||||
|
|
||||||
|
// TypeGitLab is the Type for the gitlab alerting provider
|
||||||
|
TypeGitLab Type = "gitlab"
|
||||||
|
|
||||||
|
// TypeGoogleChat is the Type for the googlechat alerting provider
|
||||||
|
TypeGoogleChat Type = "googlechat"
|
||||||
|
|
||||||
|
// TypeGotify is the Type for the gotify alerting provider
|
||||||
|
TypeGotify Type = "gotify"
|
||||||
|
|
||||||
|
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
||||||
|
TypeJetBrainsSpace Type = "jetbrainsspace"
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
// TypePushover is the Type for the pushover alerting provider
|
||||||
|
TypePushover Type = "pushover"
|
||||||
|
|
||||||
// TypeSlack is the Type for the slack alerting provider
|
// TypeSlack is the Type for the slack alerting provider
|
||||||
TypeSlack Type = "slack"
|
TypeSlack Type = "slack"
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
package alerting
|
package alerting
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"log"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider"
|
"reflect"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
"strings"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/discord"
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/email"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/slack"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/teams"
|
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
|
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the configuration for alerting providers
|
// Config is the configuration for alerting providers
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
|
||||||
|
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
|
||||||
|
|
||||||
// Custom is the configuration for the custom alerting provider
|
// Custom is the configuration for the custom alerting provider
|
||||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||||
|
|
||||||
@@ -26,15 +43,42 @@ type Config struct {
|
|||||||
// Email is the configuration for the email alerting provider
|
// Email is the configuration for the email alerting provider
|
||||||
Email *email.AlertProvider `yaml:"email,omitempty"`
|
Email *email.AlertProvider `yaml:"email,omitempty"`
|
||||||
|
|
||||||
|
// GitHub is the configuration for the github alerting provider
|
||||||
|
GitHub *github.AlertProvider `yaml:"github,omitempty"`
|
||||||
|
|
||||||
|
// GitLab is the configuration for the gitlab alerting provider
|
||||||
|
GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"`
|
||||||
|
|
||||||
|
// GoogleChat is the configuration for the googlechat alerting provider
|
||||||
|
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
|
||||||
|
|
||||||
|
// Gotify is the configuration for the gotify alerting provider
|
||||||
|
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
||||||
|
|
||||||
|
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
||||||
|
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,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,omitempty"`
|
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,omitempty"`
|
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,omitempty"`
|
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
|
||||||
|
|
||||||
|
// Pushover is the configuration for the pushover alerting provider
|
||||||
|
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
|
||||||
|
|
||||||
// Slack is the configuration for the slack alerting provider
|
// Slack is the configuration for the slack alerting provider
|
||||||
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
||||||
|
|
||||||
@@ -49,68 +93,31 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
||||||
func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
|
func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
|
||||||
switch alertType {
|
entityType := reflect.TypeOf(config).Elem()
|
||||||
case alert.TypeCustom:
|
for i := 0; i < entityType.NumField(); i++ {
|
||||||
if config.Custom == nil {
|
field := entityType.Field(i)
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
tag := strings.Split(field.Tag.Get("yaml"), ",")[0]
|
||||||
return nil
|
if tag == string(alertType) {
|
||||||
|
fieldValue := reflect.ValueOf(config).Elem().Field(i)
|
||||||
|
if fieldValue.IsNil() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fieldValue.Interface().(provider.AlertProvider)
|
||||||
}
|
}
|
||||||
return config.Custom
|
|
||||||
case alert.TypeDiscord:
|
|
||||||
if config.Discord == nil {
|
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
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.TypeMattermost:
|
|
||||||
if config.Mattermost == nil {
|
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return config.Mattermost
|
|
||||||
case alert.TypeMessagebird:
|
|
||||||
if config.Messagebird == nil {
|
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return config.Messagebird
|
|
||||||
case alert.TypePagerDuty:
|
|
||||||
if config.PagerDuty == nil {
|
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return config.PagerDuty
|
|
||||||
case alert.TypeSlack:
|
|
||||||
if config.Slack == nil {
|
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return config.Slack
|
|
||||||
case alert.TypeTeams:
|
|
||||||
if config.Teams == nil {
|
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return config.Teams
|
|
||||||
case alert.TypeTelegram:
|
|
||||||
if config.Telegram == nil {
|
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return config.Telegram
|
|
||||||
case alert.TypeTwilio:
|
|
||||||
if config.Twilio == nil {
|
|
||||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return config.Twilio
|
|
||||||
}
|
}
|
||||||
|
log.Printf("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAlertingProviderToNil Sets an alerting provider to nil to avoid having to revalidate it every time an
|
||||||
|
// alert of its corresponding type is sent.
|
||||||
|
func (config *Config) SetAlertingProviderToNil(p provider.AlertProvider) {
|
||||||
|
entityType := reflect.TypeOf(config).Elem()
|
||||||
|
for i := 0; i < entityType.NumField(); i++ {
|
||||||
|
field := entityType.Field(i)
|
||||||
|
if field.Type == reflect.TypeOf(p) {
|
||||||
|
reflect.ValueOf(config).Elem().Field(i).Set(reflect.Zero(field.Type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
167
alerting/provider/awsses/awsses.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package awsses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/session"
|
||||||
|
"github.com/aws/aws-sdk-go/service/ses"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CharSet = "UTF-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
|
||||||
|
type AlertProvider struct {
|
||||||
|
AccessKeyID string `yaml:"access-key-id"`
|
||||||
|
SecretAccessKey string `yaml:"secret-access-key"`
|
||||||
|
Region string `yaml:"region"`
|
||||||
|
|
||||||
|
From string `yaml:"from"`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
|
||||||
|
// otherwise if neither are specified, then we'll fall back on IAM authentication.
|
||||||
|
return len(provider.From) > 0 && len(provider.To) > 0 &&
|
||||||
|
((len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) || (len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
sess, err := provider.createSession()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
svc := ses.New(sess)
|
||||||
|
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||||
|
emails := strings.Split(provider.getToForGroup(ep.Group), ",")
|
||||||
|
|
||||||
|
input := &ses.SendEmailInput{
|
||||||
|
Destination: &ses.Destination{
|
||||||
|
ToAddresses: aws.StringSlice(emails),
|
||||||
|
},
|
||||||
|
Message: &ses.Message{
|
||||||
|
Body: &ses.Body{
|
||||||
|
Text: &ses.Content{
|
||||||
|
Charset: aws.String(CharSet),
|
||||||
|
Data: aws.String(body),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Subject: &ses.Content{
|
||||||
|
Charset: aws.String(CharSet),
|
||||||
|
Data: aws.String(subject),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Source: aws.String(provider.From),
|
||||||
|
}
|
||||||
|
_, err = svc.SendEmail(input)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if aerr, ok := err.(awserr.Error); ok {
|
||||||
|
switch aerr.Code() {
|
||||||
|
case ses.ErrCodeMessageRejected:
|
||||||
|
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error())
|
||||||
|
case ses.ErrCodeMailFromDomainNotVerifiedException:
|
||||||
|
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error())
|
||||||
|
case ses.ErrCodeConfigurationSetDoesNotExistException:
|
||||||
|
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error())
|
||||||
|
default:
|
||||||
|
fmt.Println(aerr.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Print the error, cast err to awserr.Error to get the Code and
|
||||||
|
// Message from an error.
|
||||||
|
fmt.Println(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessageSubjectAndBody builds the message subject and body
|
||||||
|
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
||||||
|
var subject, message string
|
||||||
|
if resolved {
|
||||||
|
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
formattedConditionResults = "\n\nCondition results:\n"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += 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 + formattedConditionResults
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) createSession() (*session.Session, error) {
|
||||||
|
config := &aws.Config{
|
||||||
|
Region: aws.String(provider.Region),
|
||||||
|
}
|
||||||
|
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
|
||||||
|
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
|
||||||
|
}
|
||||||
|
return session.NewSession(config)
|
||||||
|
}
|
||||||
188
alerting/provider/awsses/awsses_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package awsses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{}
|
||||||
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}
|
||||||
|
if invalidProviderWithOneKey.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}
|
||||||
|
if !validProviderWithKeys.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",
|
||||||
|
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(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ package custom
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -22,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 endpoints 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
|
||||||
@@ -50,68 +50,49 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription string, resolved bool) *http.Request {
|
func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.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]", ep.Name)
|
||||||
if strings.Contains(body, "[ALERT_DESCRIPTION]") {
|
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
|
||||||
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription)
|
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
|
||||||
}
|
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
|
||||||
if strings.Contains(body, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
|
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
|
||||||
body = strings.ReplaceAll(body, "[SERVICE_NAME]", endpointName)
|
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
|
||||||
}
|
if resolved {
|
||||||
if strings.Contains(body, "[ENDPOINT_NAME]") {
|
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||||
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpointName)
|
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||||
}
|
} else {
|
||||||
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
|
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
||||||
if resolved {
|
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
||||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
|
||||||
} else {
|
|
||||||
body = strings.ReplaceAll(body, "[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]") { // XXX: Remove this in v4.0.0
|
|
||||||
providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", endpointName)
|
|
||||||
}
|
|
||||||
if strings.Contains(providerURL, "[ENDPOINT_NAME]") {
|
|
||||||
providerURL = strings.ReplaceAll(providerURL, "[ENDPOINT_NAME]", endpointName)
|
|
||||||
}
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
request := provider.buildHTTPRequest(endpoint.Name, alert.GetDescription(), resolved)
|
request := provider.buildHTTPRequest(ep, alert, resolved)
|
||||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, _ := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
package custom
|
package custom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v3/test"
|
"github.com/TwiN/gatus/v5/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")
|
||||||
validProvider := AlertProvider{URL: "https://example.com"}
|
}
|
||||||
if !validProvider.IsValid() {
|
})
|
||||||
t.Error("provider should've 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) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
@@ -79,10 +90,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -99,77 +110,103 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
|
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
||||||
const (
|
|
||||||
ExpectedURL = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description"
|
|
||||||
ExpectedBody = "endpoint-name,alert-description,RESOLVED"
|
|
||||||
)
|
|
||||||
customAlertProvider := &AlertProvider{
|
customAlertProvider := &AlertProvider{
|
||||||
URL: "https://example.com/[ENDPOINT_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: "[ENDPOINT_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("endpoint-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(
|
||||||
}
|
&endpoint.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 = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
|
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
|
||||||
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
|
}
|
||||||
)
|
body, _ := io.ReadAll(request.Body)
|
||||||
customAlertProvider := &AlertProvider{
|
if string(body) != scenario.ExpectedBody {
|
||||||
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
|
||||||
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
}
|
||||||
Headers: map[string]string{"Authorization": "Basic hunter2"},
|
})
|
||||||
}
|
|
||||||
request := customAlertProvider.buildHTTPRequest("endpoint-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_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||||
const (
|
|
||||||
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"
|
|
||||||
ExpectedBody = "endpoint-name,alert-description,test"
|
|
||||||
)
|
|
||||||
customAlertProvider := &AlertProvider{
|
customAlertProvider := &AlertProvider{
|
||||||
URL: "https://example.com/[ENDPOINT_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: "[ENDPOINT_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("endpoint-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(
|
||||||
|
&endpoint.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: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||||
Body: "[ENDPOINT_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))
|
||||||
@@ -180,33 +217,10 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAlertProvider_isBackwardCompatibleWithServiceRename checks if the custom alerting provider still supports
|
|
||||||
// service placeholders after the migration from "service" to "endpoint"
|
|
||||||
//
|
|
||||||
// XXX: Remove this in v4.0.0
|
|
||||||
func TestAlertProvider_isBackwardCompatibleWithServiceRename(t *testing.T) {
|
|
||||||
const (
|
|
||||||
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
|
|
||||||
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
|
|
||||||
)
|
|
||||||
customAlertProvider := &AlertProvider{
|
|
||||||
URL: "https://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
|
||||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
|
||||||
}
|
|
||||||
request := customAlertProvider.buildHTTPRequest("endpoint-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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package discord
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
@@ -16,18 +17,39 @@ type AlertProvider struct {
|
|||||||
WebhookURL string `yaml:"webhook-url"`
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints 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"`
|
||||||
|
|
||||||
|
// Title is the title of the message that will be sent
|
||||||
|
Title string `yaml:"title,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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -36,24 +58,44 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, _ := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Embeds []Embed `json:"embeds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Embed struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Color int `json:"color"`
|
||||||
|
Fields []Field `json:"fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Field struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Inline bool `json:"inline"`
|
||||||
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, results string
|
var message 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", endpoint.Name, alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.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", endpoint.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
colorCode = 15158332
|
colorCode = 15158332
|
||||||
}
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
if conditionResult.Success {
|
if conditionResult.Success {
|
||||||
@@ -61,32 +103,50 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
} else {
|
} else {
|
||||||
prefix = ":x:"
|
prefix = ":x:"
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\\n> " + alertDescription
|
description = ":\n> " + alertDescription
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(`{
|
title := ":helmet_with_white_cross: Gatus"
|
||||||
"content": "",
|
if provider.Title != "" {
|
||||||
"embeds": [
|
title = provider.Title
|
||||||
{
|
}
|
||||||
"title": ":helmet_with_white_cross: Gatus",
|
body := Body{
|
||||||
"description": "%s%s",
|
Content: "",
|
||||||
"color": %d,
|
Embeds: []Embed{
|
||||||
"fields": [
|
{
|
||||||
{
|
Title: title,
|
||||||
"name": "Condition results",
|
Description: message + description,
|
||||||
"value": "%s",
|
Color: colorCode,
|
||||||
"inline": false
|
},
|
||||||
}
|
},
|
||||||
]
|
}
|
||||||
}
|
if len(formattedConditionResults) > 0 {
|
||||||
]
|
body.Embeds[0].Fields = append(body.Embeds[0].Fields, Field{
|
||||||
}`, message, description, colorCode, results)
|
Name: "Condition results",
|
||||||
|
Value: formattedConditionResults,
|
||||||
|
Inline: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v3/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -22,10 +22,48 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
defer client.InjectHTTPClient(nil)
|
defer client.InjectHTTPClient(nil)
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
|
title := "provider-title"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
@@ -74,15 +112,25 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-modified-title",
|
||||||
|
Provider: AlertProvider{Title: title},
|
||||||
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -102,10 +150,12 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
|
title := "provider-title"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
@@ -114,34 +164,54 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
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}",
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
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}",
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-modified-title",
|
||||||
|
Provider: AlertProvider{Title: title},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Provider: AlertProvider{Title: title},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
var conditionResults []*endpoint.ConditionResult
|
||||||
|
if !scenario.NoConditions {
|
||||||
|
conditionResults = []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
|
||||||
|
}
|
||||||
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: conditionResults,
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if body != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{})
|
out := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -149,10 +219,73 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be 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,71 +1,134 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
gomail "gopkg.in/mail.v2"
|
gomail "gopkg.in/mail.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
From string `yaml:"from"`
|
From string `yaml:"from"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
To string `yaml:"to"`
|
To string `yaml:"to"`
|
||||||
|
|
||||||
|
// 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 is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
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
|
// IsValid returns whether the provider's configuration is valid
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
|
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.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
|
var username string
|
||||||
|
if len(provider.Username) > 0 {
|
||||||
|
username = provider.Username
|
||||||
|
} else {
|
||||||
|
username = provider.From
|
||||||
|
}
|
||||||
|
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||||
m := gomail.NewMessage()
|
m := gomail.NewMessage()
|
||||||
m.SetHeader("From", provider.From)
|
m.SetHeader("From", provider.From)
|
||||||
m.SetHeader("To", strings.Split(provider.To, ",")...)
|
m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...)
|
||||||
m.SetHeader("Subject", subject)
|
m.SetHeader("Subject", subject)
|
||||||
m.SetBody("text/plain", body)
|
m.SetBody("text/plain", body)
|
||||||
d := gomail.NewDialer(provider.Host, provider.Port, provider.From, provider.Password)
|
var d *gomail.Dialer
|
||||||
|
if len(provider.Password) == 0 {
|
||||||
|
// Get the domain in the From address
|
||||||
|
localName := "localhost"
|
||||||
|
fromParts := strings.Split(provider.From, `@`)
|
||||||
|
if len(fromParts) == 2 {
|
||||||
|
localName = fromParts[1]
|
||||||
|
}
|
||||||
|
// Create a dialer with no authentication
|
||||||
|
d = &gomail.Dialer{Host: provider.Host, Port: provider.Port, LocalName: localName}
|
||||||
|
} else {
|
||||||
|
// Create an authenticated dialer
|
||||||
|
d = gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
|
||||||
|
}
|
||||||
|
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
|
||||||
|
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||||
|
}
|
||||||
return d.DialAndSend(m)
|
return d.DialAndSend(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMessageSubjectAndBody builds the message subject and body
|
// buildMessageSubjectAndBody builds the message subject and body
|
||||||
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
|
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
||||||
var subject, message, results string
|
var subject, message string
|
||||||
if resolved {
|
if resolved {
|
||||||
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.Name)
|
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
|
||||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.Name)
|
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
|
||||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
for _, conditionResult := range result.ConditionResults {
|
var formattedConditionResults string
|
||||||
var prefix string
|
if len(result.ConditionResults) > 0 {
|
||||||
if conditionResult.Success {
|
formattedConditionResults = "\n\nCondition results:\n"
|
||||||
prefix = "✅"
|
for _, conditionResult := range result.ConditionResults {
|
||||||
} else {
|
var prefix string
|
||||||
prefix = "❌"
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = "\n\nAlert description: " + alertDescription
|
description = "\n\nAlert description: " + alertDescription
|
||||||
}
|
}
|
||||||
return subject, message + description + "\n\nCondition results:\n" + results
|
return subject, message + description + formattedConditionResults
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package email
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{}
|
invalidProvider := AlertProvider{}
|
||||||
if invalidProvider.IsValid() {
|
if invalidProvider.IsValid() {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
@@ -18,6 +18,54 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_IsValidWithNoCredentials(t *testing.T) {
|
||||||
|
validProvider := AlertProvider{From: "from@example.com", Host: "smtp-relay.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) {
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
@@ -49,10 +97,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -70,10 +118,73 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
132
alerting/provider/github/github.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/google/go-github/v48/github"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
|
type AlertProvider struct {
|
||||||
|
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
|
||||||
|
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
username string
|
||||||
|
repositoryOwner string
|
||||||
|
repositoryName string
|
||||||
|
githubClient *github.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether the provider's configuration is valid
|
||||||
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Validate format of the repository URL
|
||||||
|
repositoryURL, err := url.Parse(provider.RepositoryURL)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
||||||
|
pathParts := strings.Split(repositoryURL.Path, "/")
|
||||||
|
if len(pathParts) != 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
provider.repositoryOwner = pathParts[1]
|
||||||
|
provider.repositoryName = pathParts[2]
|
||||||
|
// Create oauth2 HTTP client with GitHub token
|
||||||
|
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
|
||||||
|
AccessToken: provider.Token,
|
||||||
|
}))
|
||||||
|
// Create GitHub client
|
||||||
|
if baseURL == "https://github.com" {
|
||||||
|
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource)
|
||||||
|
} else {
|
||||||
|
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Retrieve the username once to validate that the token is valid
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
user, _, err := provider.githubClient.Users.Get(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
provider.username = *user.Login
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||||
|
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
title := "alert(gatus): " + ep.DisplayName()
|
||||||
|
if !resolved {
|
||||||
|
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{
|
||||||
|
Title: github.String(title),
|
||||||
|
Body: github.String(provider.buildIssueBody(ep, alert, result)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create issue: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
issues, _, err := provider.githubClient.Issues.ListByRepo(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueListByRepoOptions{
|
||||||
|
State: "open",
|
||||||
|
Creator: provider.username,
|
||||||
|
ListOptions: github.ListOptions{PerPage: 100},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list issues: %w", err)
|
||||||
|
}
|
||||||
|
for _, issue := range issues {
|
||||||
|
if *issue.Title == title {
|
||||||
|
_, _, err = provider.githubClient.Issues.Edit(context.Background(), provider.repositoryOwner, provider.repositoryName, *issue.Number, &github.IssueRequest{
|
||||||
|
State: github.String("closed"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to close issue: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildIssueBody builds the body of the issue
|
||||||
|
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
|
||||||
|
var formattedConditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
formattedConditionResults = "\n\n## Condition results\n"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = ":white_check_mark:"
|
||||||
|
} else {
|
||||||
|
prefix = ":x:"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = ":\n> " + alertDescription
|
||||||
|
}
|
||||||
|
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
return message + description + formattedConditionResults
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
169
alerting/provider/github/github_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
"github.com/google/go-github/v48/github"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "invalid",
|
||||||
|
Provider: AlertProvider{RepositoryURL: "", Token: ""},
|
||||||
|
Expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "invalid-token",
|
||||||
|
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||||
|
Expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-repository-name",
|
||||||
|
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"},
|
||||||
|
Expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "enterprise-client",
|
||||||
|
Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"},
|
||||||
|
Expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "invalid-url",
|
||||||
|
Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"},
|
||||||
|
Expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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_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-error",
|
||||||
|
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
scenario.Provider.githubClient = github.NewClient(nil)
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
||||||
|
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-description",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{FailureThreshold: 10},
|
||||||
|
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
|
||||||
|
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
var conditionResults []*endpoint.ConditionResult
|
||||||
|
if !scenario.NoConditions {
|
||||||
|
conditionResults = []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: true},
|
||||||
|
{Condition: "[STATUS] == 200", Success: false},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := scenario.Provider.buildIssueBody(
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{ConditionResults: conditionResults},
|
||||||
|
)
|
||||||
|
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%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")
|
||||||
|
}
|
||||||
|
}
|
||||||
150
alerting/provider/gitlab/gitlab.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package gitlab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using GitLab
|
||||||
|
type AlertProvider struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
|
||||||
|
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
|
||||||
|
Severity string `yaml:"severity,omitempty"`
|
||||||
|
|
||||||
|
// MonitoringTool overrides the name sent to gitlab. Defaults to gatus
|
||||||
|
MonitoringTool string `yaml:"monitoring-tool,omitempty"`
|
||||||
|
|
||||||
|
// EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
|
||||||
|
EnvironmentName string `yaml:"environment-name,omitempty"`
|
||||||
|
|
||||||
|
// Service affected. Defaults to endpoint display name
|
||||||
|
Service string `yaml:"service,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether the provider's configuration is valid
|
||||||
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Validate format of the repository URL
|
||||||
|
_, err := url.Parse(provider.WebhookURL)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||||
|
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
if len(alert.ResolveKey) == 0 {
|
||||||
|
alert.ResolveKey = uuid.NewString()
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.AuthorizationKey))
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertBody struct {
|
||||||
|
Title string `json:"title,omitempty"` // The title of the alert.
|
||||||
|
Description string `json:"description,omitempty"` // A high-level summary of the problem.
|
||||||
|
StartTime string `json:"start_time,omitempty"` // The time of the alert. If none is provided, a current time is used.
|
||||||
|
EndTime string `json:"end_time,omitempty"` // The resolution time of the alert. If provided, the alert is resolved.
|
||||||
|
Service string `json:"service,omitempty"` // The affected service.
|
||||||
|
MonitoringTool string `json:"monitoring_tool,omitempty"` // The name of the associated monitoring tool.
|
||||||
|
Hosts string `json:"hosts,omitempty"` // One or more hosts, as to where this incident occurred.
|
||||||
|
Severity string `json:"severity,omitempty"` // The severity of the alert. Case-insensitive. Can be one of: critical, high, medium, low, info, unknown. Defaults to critical if missing or value is not in this list.
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"` // The unique identifier of the alert. This can be used to group occurrences of the same alert.
|
||||||
|
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) monitoringTool() string {
|
||||||
|
if len(provider.MonitoringTool) > 0 {
|
||||||
|
return provider.MonitoringTool
|
||||||
|
}
|
||||||
|
return "gatus"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) service(ep *endpoint.Endpoint) string {
|
||||||
|
if len(provider.Service) > 0 {
|
||||||
|
return provider.Service
|
||||||
|
}
|
||||||
|
return ep.DisplayName()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAlertBody builds the body of the alert
|
||||||
|
func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
body := AlertBody{
|
||||||
|
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(ep)),
|
||||||
|
StartTime: result.Timestamp.Format(time.RFC3339),
|
||||||
|
Service: provider.service(ep),
|
||||||
|
MonitoringTool: provider.monitoringTool(),
|
||||||
|
Hosts: ep.URL,
|
||||||
|
GitlabEnvironmentName: provider.EnvironmentName,
|
||||||
|
Severity: provider.Severity,
|
||||||
|
Fingerprint: alert.ResolveKey,
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
body.EndTime = result.Timestamp.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
formattedConditionResults = "\n\n## Condition results\n"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = ":white_check_mark:"
|
||||||
|
} else {
|
||||||
|
prefix = ":x:"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = ":\n> " + alertDescription
|
||||||
|
}
|
||||||
|
var message string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
body.Description = message + description + formattedConditionResults
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
158
alerting/provider/gitlab/gitlab_test.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package gitlab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "invalid",
|
||||||
|
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""},
|
||||||
|
Expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-webhook-url",
|
||||||
|
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"},
|
||||||
|
Expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-authorization-key",
|
||||||
|
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""},
|
||||||
|
Expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "invalid-url",
|
||||||
|
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"},
|
||||||
|
Expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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_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-error",
|
||||||
|
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
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{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, 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}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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_buildAlertBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
||||||
|
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "no-description",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{FailureThreshold: 10},
|
||||||
|
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildAlertBody(
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: true},
|
||||||
|
{Condition: "[STATUS] == 200", Success: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if strings.TrimSpace(string(body)) != strings.TrimSpace(scenario.ExpectedBody) {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%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")
|
||||||
|
}
|
||||||
|
}
|
||||||
203
alerting/provider/googlechat/googlechat.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package googlechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.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
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Cards []Cards `json:"cards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cards struct {
|
||||||
|
Sections []Sections `json:"sections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sections struct {
|
||||||
|
Widgets []Widgets `json:"widgets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Widgets struct {
|
||||||
|
KeyValue *KeyValue `json:"keyValue,omitempty"`
|
||||||
|
Buttons []Buttons `json:"buttons,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyValue struct {
|
||||||
|
TopLabel string `json:"topLabel,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ContentMultiline string `json:"contentMultiline,omitempty"`
|
||||||
|
BottomLabel string `json:"bottomLabel,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Buttons struct {
|
||||||
|
TextButton TextButton `json:"textButton"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextButton struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
OnClick OnClick `json:"onClick"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnClick struct {
|
||||||
|
OpenLink OpenLink `json:"openLink"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenLink struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
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 formattedConditionResults string
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = ":: " + alertDescription
|
||||||
|
}
|
||||||
|
payload := Body{
|
||||||
|
Cards: []Cards{
|
||||||
|
{
|
||||||
|
Sections: []Sections{
|
||||||
|
{
|
||||||
|
Widgets: []Widgets{
|
||||||
|
{
|
||||||
|
KeyValue: &KeyValue{
|
||||||
|
TopLabel: ep.DisplayName(),
|
||||||
|
Content: message,
|
||||||
|
ContentMultiline: "true",
|
||||||
|
BottomLabel: description,
|
||||||
|
Icon: "BOOKMARK",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if len(formattedConditionResults) > 0 {
|
||||||
|
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
|
||||||
|
KeyValue: &KeyValue{
|
||||||
|
TopLabel: "Condition results",
|
||||||
|
Content: formattedConditionResults,
|
||||||
|
ContentMultiline: "true",
|
||||||
|
Icon: "DESCRIPTION",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ep.Type() == endpoint.TypeHTTP {
|
||||||
|
// We only include a button targeting the URL if the endpoint is an HTTP endpoint
|
||||||
|
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
|
||||||
|
// See https://github.com/TwiN/gatus/issues/362
|
||||||
|
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
|
||||||
|
Buttons: []Buttons{
|
||||||
|
{
|
||||||
|
TextButton: TextButton{
|
||||||
|
Text: "URL",
|
||||||
|
OnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(payload)
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
277
alerting/provider/googlechat/googlechat_test.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package googlechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/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(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-2","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal(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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
106
alerting/provider/gotify/gotify.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package gotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultPriority = 5
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Gotify
|
||||||
|
type AlertProvider struct {
|
||||||
|
// ServerURL is the URL of the Gotify server
|
||||||
|
ServerURL string `yaml:"server-url"`
|
||||||
|
|
||||||
|
// Token is the token to use when sending a message to the Gotify server
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
|
||||||
|
// Priority is the priority of the message
|
||||||
|
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"`
|
||||||
|
|
||||||
|
// Title is the title of the message that will be sent
|
||||||
|
Title string `yaml:"title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns whether the provider's configuration is valid
|
||||||
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
if provider.Priority == 0 {
|
||||||
|
provider.Priority = DefaultPriority
|
||||||
|
}
|
||||||
|
return len(provider.ServerURL) > 0 && len(provider.Token) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, 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
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("failed to send alert to Gotify: %s", string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
var message string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✓"
|
||||||
|
} else {
|
||||||
|
prefix = "✕"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
if len(alert.GetDescription()) > 0 {
|
||||||
|
message += " with the following description: " + alert.GetDescription()
|
||||||
|
}
|
||||||
|
message += formattedConditionResults
|
||||||
|
title := "Gatus: " + ep.DisplayName()
|
||||||
|
if provider.Title != "" {
|
||||||
|
title = provider.Title
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(Body{
|
||||||
|
Message: message,
|
||||||
|
Title: title,
|
||||||
|
Priority: provider.Priority,
|
||||||
|
})
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
105
alerting/provider/gotify/gotify_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package gotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-server-url",
|
||||||
|
provider: AlertProvider{ServerURL: "", Token: "faketoken"},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-app-token",
|
||||||
|
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-priority-should-use-default-value",
|
||||||
|
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
||||||
|
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) {
|
||||||
|
var (
|
||||||
|
description = "custom-description"
|
||||||
|
//title = "custom-title"
|
||||||
|
endpointName = "custom-endpoint"
|
||||||
|
)
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "custom-title",
|
||||||
|
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&endpoint.Endpoint{Name: endpointName},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
162
alerting/provider/jetbrainsspace/jetbrainsspace.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package jetbrainsspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
|
||||||
|
type AlertProvider struct {
|
||||||
|
Project string `yaml:"project"` // JetBrains Space Project name
|
||||||
|
ChannelID string `yaml:"channel-id"` // JetBrains Space Chat Channel ID
|
||||||
|
Token string `yaml:"token"` // JetBrains Space Bearer Token
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
ChannelID string `yaml:"channel-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.ChannelID) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(provider.Project) > 0 && len(provider.ChannelID) > 0 && len(provider.Token) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
|
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+provider.Token)
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Content Content `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Content struct {
|
||||||
|
ClassName string `json:"className"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
Sections []Section `json:"sections,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Section struct {
|
||||||
|
ClassName string `json:"className"`
|
||||||
|
Elements []Element `json:"elements"`
|
||||||
|
Header string `json:"header"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Element struct {
|
||||||
|
ClassName string `json:"className"`
|
||||||
|
Accessory Accessory `json:"accessory"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Accessory struct {
|
||||||
|
ClassName string `json:"className"`
|
||||||
|
Icon Icon `json:"icon"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Icon struct {
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
body := Body{
|
||||||
|
Channel: "id:" + provider.getChannelIDForGroup(ep.Group),
|
||||||
|
Content: Content{
|
||||||
|
ClassName: "ChatMessage.Block",
|
||||||
|
Sections: []Section{{
|
||||||
|
ClassName: "MessageSection",
|
||||||
|
Elements: []Element{},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
body.Content.Style = "SUCCESS"
|
||||||
|
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
body.Content.Style = "WARNING"
|
||||||
|
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
icon := "warning"
|
||||||
|
style := "WARNING"
|
||||||
|
if conditionResult.Success {
|
||||||
|
icon = "success"
|
||||||
|
style = "SUCCESS"
|
||||||
|
}
|
||||||
|
body.Content.Sections[0].Elements = append(body.Content.Sections[0].Elements, Element{
|
||||||
|
ClassName: "MessageText",
|
||||||
|
Accessory: Accessory{
|
||||||
|
ClassName: "MessageIcon",
|
||||||
|
Icon: Icon{Icon: icon},
|
||||||
|
Style: style,
|
||||||
|
},
|
||||||
|
Style: style,
|
||||||
|
Size: "REGULAR",
|
||||||
|
Content: conditionResult.Condition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// getChannelIDForGroup returns the appropriate channel ID to for a given group override
|
||||||
|
func (provider *AlertProvider) getChannelIDForGroup(group string) string {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.ChannelID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.ChannelID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
279
alerting/provider/jetbrainsspace/jetbrainsspace_test.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
package jetbrainsspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{Project: ""}
|
||||||
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Project: "foobar",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
ChannelID: "http://example.com",
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Project: "foobar",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
ChannelID: "",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideTo.IsValid() {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
Project: "foo",
|
||||||
|
ChannelID: "bar",
|
||||||
|
Token: "baz",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
ChannelID: "foobar",
|
||||||
|
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(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-group",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-group",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal(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_getChannelIDForGroup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
ExpectedOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
ChannelID: "bar",
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
ChannelID: "bar",
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
ChannelID: "bar",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
ChannelID: "foobar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: "bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
ChannelID: "bar",
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
ChannelID: "foobar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: "foobar",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.Name, func(t *testing.T) {
|
||||||
|
if got := tt.Provider.getChannelIDForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||||
|
t.Errorf("AlertProvider.getChannelIDForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
196
alerting/provider/matrix/matrix.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Matrix
|
||||||
|
type AlertProvider struct {
|
||||||
|
ProviderConfig `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"`
|
||||||
|
|
||||||
|
ProviderConfig `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultServerURL = "https://matrix-client.matrix.org"
|
||||||
|
|
||||||
|
type ProviderConfig 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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
|
config := provider.getConfigForGroup(ep.Group)
|
||||||
|
if config.ServerURL == "" {
|
||||||
|
config.ServerURL = defaultServerURL
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
FormattedBody string `json:"formatted_body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
body, _ := json.Marshal(Body{
|
||||||
|
MsgType: "m.text",
|
||||||
|
Format: "org.matrix.custom.html",
|
||||||
|
Body: buildPlaintextMessageBody(ep, alert, result, resolved),
|
||||||
|
FormattedBody: buildHTMLMessageBody(ep, alert, result, resolved),
|
||||||
|
})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPlaintextMessageBody builds the message body in plaintext to include in request
|
||||||
|
func buildPlaintextMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||||
|
var message string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✓"
|
||||||
|
} else {
|
||||||
|
prefix = "✕"
|
||||||
|
}
|
||||||
|
formattedConditionResults += 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, formattedConditionResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHTMLMessageBody builds the message body in HTML to include in request
|
||||||
|
func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||||
|
var message string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", ep.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", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
formattedConditionResults = "\n<h5>Condition results</h5><ul>"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
formattedConditionResults += "</ul>"
|
||||||
|
}
|
||||||
|
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%s", message, description, formattedConditionResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getConfigForGroup returns the appropriate configuration for a given group
|
||||||
|
func (provider *AlertProvider) getConfigForGroup(group string) ProviderConfig {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
return override.ProviderConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.ProviderConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
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/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
AccessToken: "",
|
||||||
|
InternalRoomID: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
validProviderWithHomeserver := AlertProvider{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
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: "",
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
AccessToken: "",
|
||||||
|
InternalRoomID: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideGroup.IsValid() {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
AccessToken: "",
|
||||||
|
InternalRoomID: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideTo.IsValid() {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
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(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"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\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been triggered due to having failed 3 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-1\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"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\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been resolved after passing successfully 5 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-2\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal(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 ProviderConfig
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: ProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: ProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
ServerURL: "https://example01.com",
|
||||||
|
AccessToken: "12",
|
||||||
|
InternalRoomID: "!a:example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
ExpectedOutput: ProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
ProviderConfig: ProviderConfig{
|
||||||
|
ServerURL: "https://example01.com",
|
||||||
|
AccessToken: "12",
|
||||||
|
InternalRoomID: "!a:example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
ExpectedOutput: ProviderConfig{
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@ package mattermost
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
||||||
@@ -16,10 +17,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 endpoints 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
|
||||||
@@ -27,13 +37,22 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -42,66 +61,100 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, _ := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
IconURL string `json:"icon_url"`
|
||||||
|
Attachments []Attachment `json:"attachments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Fallback string `json:"fallback"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Short bool `json:"short"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Fields []Field `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Field struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Short bool `json:"short"`
|
||||||
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color 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", endpoint.Name, alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.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", endpoint.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
}
|
}
|
||||||
var results string
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
if len(result.ConditionResults) > 0 {
|
||||||
var prefix string
|
for _, conditionResult := range result.ConditionResults {
|
||||||
if conditionResult.Success {
|
var prefix string
|
||||||
prefix = ":white_check_mark:"
|
if conditionResult.Success {
|
||||||
} else {
|
prefix = ":white_check_mark:"
|
||||||
prefix = ":x:"
|
} else {
|
||||||
|
prefix = ":x:"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\\n> " + alertDescription
|
description = ":\n> " + alertDescription
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(`{
|
body := Body{
|
||||||
"text": "",
|
Text: "",
|
||||||
"username": "gatus",
|
Username: "gatus",
|
||||||
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||||
"attachments": [
|
Attachments: []Attachment{
|
||||||
{
|
{
|
||||||
"title": ":rescue_worker_helmet: Gatus",
|
Title: ":helmet_with_white_cross: Gatus",
|
||||||
"fallback": "Gatus - %s",
|
Fallback: "Gatus - " + message,
|
||||||
"text": "%s%s",
|
Text: message + description,
|
||||||
"short": false,
|
Short: false,
|
||||||
"color": "%s",
|
Color: color,
|
||||||
"fields": [
|
},
|
||||||
{
|
},
|
||||||
"title": "URL",
|
}
|
||||||
"value": "%s",
|
if len(formattedConditionResults) > 0 {
|
||||||
"short": false
|
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||||
},
|
Title: "Condition results",
|
||||||
{
|
Value: formattedConditionResults,
|
||||||
"title": "Condition results",
|
Short: false,
|
||||||
"value": "%s",
|
})
|
||||||
"short": false
|
}
|
||||||
}
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
]
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}`, message, message, description, color, endpoint.URL, results)
|
// 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
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v3/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -22,6 +22,47 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
providerWithInvalidOverrideWebHookUrl := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
|
||||||
|
WebhookURL: "",
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if providerWithInvalidOverrideWebHookUrl.IsValid() {
|
||||||
|
t.Error("provider WebHookURL 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) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
defer client.InjectHTTPClient(nil)
|
defer client.InjectHTTPClient(nil)
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
@@ -79,10 +120,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -114,34 +155,34 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
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}",
|
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\"text\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
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}",
|
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if body != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{})
|
out := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -149,10 +190,73 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package messagebird
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -32,8 +33,8 @@ func (provider *AlertProvider) IsValid() bool {
|
|||||||
|
|
||||||
// Send an alert using the provider
|
// 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) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -44,29 +45,37 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, _ := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Originator string `json:"originator"`
|
||||||
|
Recipients string `json:"recipients"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(`{
|
body, _ := json.Marshal(Body{
|
||||||
"originator": "%s",
|
Originator: provider.Originator,
|
||||||
"recipients": "%s",
|
Recipients: provider.Recipients,
|
||||||
"body": "%s"
|
Body: message,
|
||||||
}`, provider.Originator, provider.Recipients, message)
|
})
|
||||||
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v3/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -83,10 +83,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -118,31 +118,31 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
|
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\n \"originator\": \"2\",\n \"recipients\": \"3\",\n \"body\": \"TRIGGERED: endpoint-name - description-1\"\n}",
|
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
|
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\n \"originator\": \"5\",\n \"recipients\": \"6\",\n \"body\": \"RESOLVED: endpoint-name - description-2\"\n}",
|
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if body != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{})
|
out := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
@@ -153,10 +153,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
115
alerting/provider/ntfy/ntfy.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
Token string `yaml:"token,omitempty"` // Defaults 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
isTokenValid := true
|
||||||
|
if len(provider.Token) > 0 {
|
||||||
|
isTokenValid = strings.HasPrefix(provider.Token, "tk_")
|
||||||
|
}
|
||||||
|
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6 && isTokenValid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
if len(provider.Token) > 0 {
|
||||||
|
request.Header.Set("Authorization", "Bearer "+provider.Token)
|
||||||
|
}
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
var message, formattedConditionResults, tag string
|
||||||
|
if resolved {
|
||||||
|
tag = "white_check_mark"
|
||||||
|
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
|
||||||
|
} else {
|
||||||
|
tag = "rotating_light"
|
||||||
|
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "🟢"
|
||||||
|
} else {
|
||||||
|
prefix = "🔴"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("\n%s %s", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
if len(alert.GetDescription()) > 0 {
|
||||||
|
message += " with the following description: " + alert.GetDescription()
|
||||||
|
}
|
||||||
|
message += formattedConditionResults
|
||||||
|
body, _ := json.Marshal(Body{
|
||||||
|
Topic: provider.Topic,
|
||||||
|
Title: "Gatus: " + ep.DisplayName(),
|
||||||
|
Message: message,
|
||||||
|
Tags: []string{tag},
|
||||||
|
Priority: provider.Priority,
|
||||||
|
})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
114
alerting/provider/ntfy/ntfy_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package ntfy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
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: "valid-with-token",
|
||||||
|
provider: AlertProvider{Topic: "example", Priority: 1, Token: "tk_faketoken"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-token",
|
||||||
|
provider: AlertProvider{Topic: "example", Priority: 1, Token: "xx_faketoken"},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal(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/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
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(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
err := provider.createAlert(ep, alert, result, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
err = provider.closeAlert(ep, 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(ep))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
payload := provider.buildCreateRequestBody(ep, alert, result, resolved)
|
||||||
|
return provider.sendRequest(restAPI, http.MethodPost, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) closeAlert(ep *endpoint.Endpoint, alert *alert.Alert) error {
|
||||||
|
payload := provider.buildCloseRequestBody(ep, alert)
|
||||||
|
url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias"
|
||||||
|
return provider.sendRequest(url, http.MethodPost, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error build alert with payload %v: %w", payload, err)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
rBody, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(rBody))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
|
||||||
|
var message, description string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
|
||||||
|
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("%s - %s", ep.Name, alert.GetDescription())
|
||||||
|
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
if ep.Group != "" {
|
||||||
|
message = fmt.Sprintf("[%s] %s", ep.Group, message)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "▣"
|
||||||
|
} else {
|
||||||
|
prefix = "▢"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
description = description + "\n" + formattedConditionResults
|
||||||
|
key := buildKey(ep)
|
||||||
|
details := map[string]string{
|
||||||
|
"endpoint:url": ep.URL,
|
||||||
|
"endpoint:group": ep.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(ep *endpoint.Endpoint, alert *alert.Alert) alertCloseRequest {
|
||||||
|
return alertCloseRequest{
|
||||||
|
Source: buildKey(ep),
|
||||||
|
Note: fmt.Sprintf("RESOLVED: %s - %s", ep.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(ep *endpoint.Endpoint) string {
|
||||||
|
name := toKebabCase(ep.Name)
|
||||||
|
if ep.Group == "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return toKebabCase(ep.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/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/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(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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 *endpoint.Endpoint
|
||||||
|
Result *endpoint.Result
|
||||||
|
Resolved bool
|
||||||
|
want alertCreateRequest
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "missing all params (unresolved)",
|
||||||
|
Provider: &AlertProvider{},
|
||||||
|
Alert: &alert.Alert{},
|
||||||
|
Endpoint: &endpoint.Endpoint{},
|
||||||
|
Result: &endpoint.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: &endpoint.Endpoint{},
|
||||||
|
Result: &endpoint.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: &endpoint.Endpoint{
|
||||||
|
Name: "my super app",
|
||||||
|
},
|
||||||
|
Result: &endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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: &endpoint.Endpoint{
|
||||||
|
Name: "my mega app",
|
||||||
|
},
|
||||||
|
Result: &endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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: &endpoint.Endpoint{
|
||||||
|
Name: "my app",
|
||||||
|
Group: "end game",
|
||||||
|
URL: "https://my.go/app",
|
||||||
|
},
|
||||||
|
Result: &endpoint.Result{
|
||||||
|
HTTPStatus: 400,
|
||||||
|
Hostname: "my.go",
|
||||||
|
Errors: []string{"error 01", "error 02"},
|
||||||
|
Success: false,
|
||||||
|
ConditionResults: []*endpoint.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 *endpoint.Endpoint
|
||||||
|
want alertCloseRequest
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Missing all values",
|
||||||
|
Provider: &AlertProvider{},
|
||||||
|
Alert: &alert.Alert{},
|
||||||
|
Endpoint: &endpoint.Endpoint{},
|
||||||
|
want: alertCloseRequest{
|
||||||
|
Source: "",
|
||||||
|
Note: "RESOLVED: - ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Basic values",
|
||||||
|
Provider: &AlertProvider{},
|
||||||
|
Alert: &alert.Alert{
|
||||||
|
Description: &description,
|
||||||
|
},
|
||||||
|
Endpoint: &endpoint.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -52,8 +52,8 @@ func (provider *AlertProvider) IsValid() bool {
|
|||||||
// Send an alert using the provider
|
// 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) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -63,8 +63,9 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, _ := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
if alert.IsSendingOnResolved() {
|
if alert.IsSendingOnResolved() {
|
||||||
@@ -73,11 +74,11 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
alert.ResolveKey = ""
|
alert.ResolveKey = ""
|
||||||
} else {
|
} else {
|
||||||
// We need to retrieve the resolve key from the response
|
// We need to retrieve the resolve key from the response
|
||||||
body, err := ioutil.ReadAll(response.Body)
|
body, err := io.ReadAll(response.Body)
|
||||||
var payload pagerDutyResponsePayload
|
var payload pagerDutyResponsePayload
|
||||||
if err = json.Unmarshal(body, &payload); err != nil {
|
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.
|
// 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())
|
log.Printf("[pagerduty.Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
|
||||||
} else {
|
} else {
|
||||||
alert.ResolveKey = payload.DedupKey
|
alert.ResolveKey = payload.DedupKey
|
||||||
}
|
}
|
||||||
@@ -86,28 +87,42 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
RoutingKey string `json:"routing_key"`
|
||||||
|
DedupKey string `json:"dedup_key"`
|
||||||
|
EventAction string `json:"event_action"`
|
||||||
|
Payload Payload `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Payload struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, eventAction, resolveKey string
|
var message, eventAction, resolveKey string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
eventAction = "resolve"
|
eventAction = "resolve"
|
||||||
resolveKey = alert.ResolveKey
|
resolveKey = alert.ResolveKey
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
eventAction = "trigger"
|
eventAction = "trigger"
|
||||||
resolveKey = ""
|
resolveKey = ""
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(`{
|
body, _ := json.Marshal(Body{
|
||||||
"routing_key": "%s",
|
RoutingKey: provider.getIntegrationKeyForGroup(ep.Group),
|
||||||
"dedup_key": "%s",
|
DedupKey: resolveKey,
|
||||||
"event_action": "%s",
|
EventAction: eventAction,
|
||||||
"payload": {
|
Payload: Payload{
|
||||||
"summary": "%s",
|
Summary: message,
|
||||||
"source": "%s",
|
Source: "Gatus",
|
||||||
"severity": "critical"
|
Severity: "critical",
|
||||||
}
|
},
|
||||||
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name)
|
})
|
||||||
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
|
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
|
||||||
@@ -123,7 +138,7 @@ func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v3/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -115,10 +115,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -149,24 +149,24 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||||
Alert: alert.Alert{Description: &description},
|
Alert: alert.Alert{Description: &description},
|
||||||
Resolved: false,
|
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}",
|
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||||
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
||||||
Resolved: true,
|
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}",
|
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved)
|
body := scenario.Provider.buildRequestBody(&endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
|
||||||
if body != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{})
|
out := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -237,10 +237,10 @@ func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
package provider
|
package provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/email"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
|
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/slack"
|
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/teams"
|
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the interface that each providers should implement
|
// AlertProvider is the interface that each provider should implement
|
||||||
type AlertProvider interface {
|
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
|
||||||
@@ -24,7 +33,7 @@ type AlertProvider interface {
|
|||||||
GetDefaultAlert() *alert.Alert
|
GetDefaultAlert() *alert.Alert
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error
|
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseWithDefaultAlert parses an Endpoint 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
|
||||||
@@ -51,12 +60,21 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// Validate interface implementation on compile
|
// Validate interface implementation on compile
|
||||||
|
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*email.AlertProvider)(nil)
|
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*github.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*jetbrainsspace.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 = (*pushover.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*teams.AlertProvider)(nil)
|
_ AlertProvider = (*teams.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package provider
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseWithDefaultAlert(t *testing.T) {
|
func TestParseWithDefaultAlert(t *testing.T) {
|
||||||
|
|||||||
112
alerting/provider/pushover/pushover.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package pushover
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
restAPIURL = "https://api.pushover.net/1/messages.json"
|
||||||
|
defaultPriority = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Pushover
|
||||||
|
type AlertProvider struct {
|
||||||
|
// Key used to authenticate the application sending
|
||||||
|
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
|
||||||
|
ApplicationToken string `yaml:"application-token"`
|
||||||
|
|
||||||
|
// Key of the user or group the messages should be sent to
|
||||||
|
UserKey string `yaml:"user-key"`
|
||||||
|
|
||||||
|
// The title of your message, likely the application name
|
||||||
|
// default: the name of your application in Pushover
|
||||||
|
Title string `yaml:"title,omitempty"`
|
||||||
|
|
||||||
|
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
|
||||||
|
// default: 0
|
||||||
|
Priority int `yaml:"priority,omitempty"`
|
||||||
|
|
||||||
|
// Sound of the messages (see: https://pushover.net/api#sounds)
|
||||||
|
// default: "" (pushover)
|
||||||
|
Sound string `yaml:"sound,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
|
||||||
|
func (provider *AlertProvider) IsValid() bool {
|
||||||
|
if provider.Priority == 0 {
|
||||||
|
provider.Priority = defaultPriority
|
||||||
|
}
|
||||||
|
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
// Reference doc for pushover: https://pushover.net/api
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, 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
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Sound string `json:"sound,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
var message string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(Body{
|
||||||
|
Token: provider.ApplicationToken,
|
||||||
|
User: provider.UserKey,
|
||||||
|
Title: provider.Title,
|
||||||
|
Message: message,
|
||||||
|
Priority: provider.priority(),
|
||||||
|
Sound: provider.Sound,
|
||||||
|
})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) priority() int {
|
||||||
|
if provider.Priority == 0 {
|
||||||
|
return defaultPriority
|
||||||
|
}
|
||||||
|
return provider.Priority
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
181
alerting/provider/pushover/pushover_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package pushover
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPushoverAlertProvider_IsValid(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{}
|
||||||
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{
|
||||||
|
ApplicationToken: "aTokenWithLengthOf30characters",
|
||||||
|
UserKey: "aTokenWithLengthOf30characters",
|
||||||
|
Title: "Gatus Notification",
|
||||||
|
Priority: 1,
|
||||||
|
}
|
||||||
|
if !validProvider.IsValid() {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushoverAlertProvider_IsInvalid(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{
|
||||||
|
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
|
||||||
|
UserKey: "aTokenWithLengthOfMoreThan30characters",
|
||||||
|
Priority: 5,
|
||||||
|
}
|
||||||
|
if invalidProvider.IsValid() {
|
||||||
|
t.Error("provider should've been invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.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{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "with-sound",
|
||||||
|
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, Sound: "falling"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,32 +2,49 @@ package slack
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -36,23 +53,44 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, _ := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Attachments []Attachment `json:"attachments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Short bool `json:"short"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Fields []Field `json:"fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Field struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Short bool `json:"short"`
|
||||||
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color, results 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", endpoint.Name, alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.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", endpoint.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
}
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
if conditionResult.Success {
|
if conditionResult.Success {
|
||||||
@@ -60,33 +98,47 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
} else {
|
} else {
|
||||||
prefix = ":x:"
|
prefix = ":x:"
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\\n> " + alertDescription
|
description = ":\n> " + alertDescription
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(`{
|
body := Body{
|
||||||
"text": "",
|
Text: "",
|
||||||
"attachments": [
|
Attachments: []Attachment{
|
||||||
{
|
{
|
||||||
"title": ":helmet_with_white_cross: Gatus",
|
Title: ":helmet_with_white_cross: Gatus",
|
||||||
"text": "%s%s",
|
Text: message + description,
|
||||||
"short": false,
|
Short: false,
|
||||||
"color": "%s",
|
Color: color,
|
||||||
"fields": [
|
},
|
||||||
{
|
},
|
||||||
"title": "Condition results",
|
}
|
||||||
"value": "%s",
|
if len(formattedConditionResults) > 0 {
|
||||||
"short": false
|
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||||
}
|
Title: "Condition results",
|
||||||
]
|
Value: formattedConditionResults,
|
||||||
}
|
Short: false,
|
||||||
]
|
})
|
||||||
}`, message, description, color, results)
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,60 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v3/test"
|
"github.com/TwiN/gatus/v5/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_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) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
defer client.InjectHTTPClient(nil)
|
defer client.InjectHTTPClient(nil)
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
@@ -79,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -105,43 +142,76 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: 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 \"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}",
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-group",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\"}]}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\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\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-group",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
var conditionResults []*endpoint.ConditionResult
|
||||||
|
if !scenario.NoConditions {
|
||||||
|
conditionResults = []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
}
|
||||||
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: conditionResults,
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if body != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{})
|
out := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -149,10 +219,73 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package teams
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Teams
|
// AlertProvider is the configuration necessary for sending an alert using Teams
|
||||||
@@ -16,18 +17,39 @@ type AlertProvider struct {
|
|||||||
WebhookURL string `yaml:"webhook-url"`
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints 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"`
|
||||||
|
|
||||||
|
// Title is the title of the message that will be sent
|
||||||
|
Title string `yaml:"title,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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -36,24 +58,39 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, _ := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Type string `json:"@type"`
|
||||||
|
Context string `json:"@context"`
|
||||||
|
ThemeColor string `json:"themeColor"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Sections []Section `json:"sections,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Section struct {
|
||||||
|
ActivityTitle string `json:"activityTitle"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color 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", endpoint.Name, alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.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", endpoint.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
}
|
}
|
||||||
var results string
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
if conditionResult.Success {
|
if conditionResult.Success {
|
||||||
@@ -61,32 +98,45 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
} else {
|
} else {
|
||||||
prefix = "❌"
|
prefix = "❌"
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\\n> " + alertDescription
|
description = ": " + alertDescription
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(`{
|
body := Body{
|
||||||
"@type": "MessageCard",
|
Type: "MessageCard",
|
||||||
"@context": "http://schema.org/extensions",
|
Context: "http://schema.org/extensions",
|
||||||
"themeColor": "%s",
|
ThemeColor: color,
|
||||||
"title": "🚨 Gatus",
|
Title: provider.Title,
|
||||||
"text": "%s%s",
|
Text: message + description,
|
||||||
"sections": [
|
}
|
||||||
{
|
if len(body.Title) == 0 {
|
||||||
"activityTitle": "URL",
|
body.Title = "🚨 Gatus"
|
||||||
"text": "%s"
|
}
|
||||||
},
|
if len(formattedConditionResults) > 0 {
|
||||||
{
|
body.Sections = append(body.Sections, Section{
|
||||||
"activityTitle": "Condition results",
|
ActivityTitle: "Condition results",
|
||||||
"text": "%s"
|
Text: formattedConditionResults,
|
||||||
}
|
})
|
||||||
]
|
}
|
||||||
}`, color, message, description, endpoint.URL, results)
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v3/test"
|
"github.com/TwiN/gatus/v5/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")
|
||||||
@@ -22,6 +22,43 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
defer client.InjectHTTPClient(nil)
|
defer client.InjectHTTPClient(nil)
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
@@ -79,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -106,6 +143,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
@@ -114,34 +152,44 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
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}",
|
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#DD0000\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row: description-1\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x274C; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x274C; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
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}",
|
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x2705; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x2705; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\"}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
var conditionResults []*endpoint.ConditionResult
|
||||||
|
if !scenario.NoConditions {
|
||||||
|
conditionResults = []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
}
|
||||||
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{ConditionResults: conditionResults},
|
||||||
ConditionResults: []*core.ConditionResult{
|
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if body != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{})
|
out := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -149,10 +197,73 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,75 +2,105 @@ package telegram
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
|
||||||
|
// 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 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
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), buffer)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, _ := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
ParseMode string `json:"parse_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, results string
|
var message 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— ", endpoint.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— ", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", ep.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
for _, conditionResult := range result.ConditionResults {
|
var formattedConditionResults string
|
||||||
var prefix string
|
if len(result.ConditionResults) > 0 {
|
||||||
if conditionResult.Success {
|
formattedConditionResults = "\n*Condition results*\n"
|
||||||
prefix = "✅"
|
for _, conditionResult := range result.ConditionResults {
|
||||||
} else {
|
var prefix string
|
||||||
prefix = "❌"
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition)
|
|
||||||
}
|
}
|
||||||
var text string
|
var text string
|
||||||
if len(alert.GetDescription()) > 0 {
|
if len(alert.GetDescription()) > 0 {
|
||||||
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Description* \\n_%s_ \\n\\n*Condition results*\\n%s", message, alert.GetDescription(), results)
|
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
|
||||||
} else {
|
} else {
|
||||||
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
|
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text)
|
bodyAsJSON, _ := json.Marshal(Body{
|
||||||
|
ChatID: provider.ID,
|
||||||
|
Text: text,
|
||||||
|
ParseMode: "MARKDOWN",
|
||||||
|
})
|
||||||
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,31 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v3/test"
|
"github.com/TwiN/gatus/v5/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")
|
||||||
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
}
|
||||||
if !validProvider.IsValid() {
|
})
|
||||||
t.Error("provider should've 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) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
@@ -79,10 +89,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -106,6 +116,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
@@ -114,34 +125,44 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{ID: "123"},
|
Provider: AlertProvider{ID: "123"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
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\"}",
|
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",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{ID: "123"},
|
Provider: AlertProvider{ID: "123"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
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\"}",
|
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 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\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
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 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
var conditionResults []*endpoint.ConditionResult
|
||||||
|
if !scenario.NoConditions {
|
||||||
|
conditionResults = []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
}
|
||||||
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{ConditionResults: conditionResults},
|
||||||
ConditionResults: []*core.ConditionResult{
|
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if body != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{})
|
out := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -149,10 +170,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
||||||
@@ -21,7 +21,7 @@ type AlertProvider struct {
|
|||||||
To string `yaml:"to"`
|
To string `yaml:"to"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints 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
|
||||||
@@ -30,8 +30,8 @@ func (provider *AlertProvider) IsValid() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, 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)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -42,20 +42,21 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
if response.StatusCode > 399 {
|
if response.StatusCode > 399 {
|
||||||
body, _ := ioutil.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
}
|
}
|
||||||
return url.Values{
|
return url.Values{
|
||||||
"To": {provider.To},
|
"To": {provider.To},
|
||||||
@@ -65,6 +66,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package twilio
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||||
@@ -51,10 +51,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -69,10 +69,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
122
api/api.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/web"
|
||||||
|
static "github.com/TwiN/gatus/v5/web"
|
||||||
|
"github.com/TwiN/health"
|
||||||
|
fiber "github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
|
fiberfs "github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/redirect"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type API struct {
|
||||||
|
router *fiber.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config) *API {
|
||||||
|
api := &API{}
|
||||||
|
if cfg.Web == nil {
|
||||||
|
log.Println("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration")
|
||||||
|
cfg.Web = web.GetDefaultConfig()
|
||||||
|
}
|
||||||
|
api.router = api.createRouter(cfg)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) Router() *fiber.App {
|
||||||
|
return a.router
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||||
|
app := fiber.New(fiber.Config{
|
||||||
|
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||||
|
log.Printf("[api.ErrorHandler] %s", err.Error())
|
||||||
|
return fiber.DefaultErrorHandler(c, err)
|
||||||
|
},
|
||||||
|
ReadBufferSize: cfg.Web.ReadBufferSize,
|
||||||
|
Network: fiber.NetworkTCP,
|
||||||
|
})
|
||||||
|
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||||
|
app.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: "http://localhost:8081",
|
||||||
|
AllowCredentials: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
// Middlewares
|
||||||
|
app.Use(recover.New())
|
||||||
|
app.Use(compress.New())
|
||||||
|
// Define metrics handler, if necessary
|
||||||
|
if cfg.Metrics {
|
||||||
|
metricsHandler := promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
|
||||||
|
DisableCompression: true,
|
||||||
|
}))
|
||||||
|
app.Get("/metrics", adaptor.HTTPHandler(metricsHandler))
|
||||||
|
}
|
||||||
|
// Define main router
|
||||||
|
apiRouter := app.Group("/api")
|
||||||
|
////////////////////////
|
||||||
|
// UNPROTECTED ROUTES //
|
||||||
|
////////////////////////
|
||||||
|
unprotectedAPIRouter := apiRouter.Group("/")
|
||||||
|
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
|
||||||
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
||||||
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
|
||||||
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
|
||||||
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
||||||
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
|
||||||
|
// This endpoint requires authz with bearer token, so technically it is protected
|
||||||
|
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
||||||
|
// SPA
|
||||||
|
app.Get("/", SinglePageApplication(cfg.UI))
|
||||||
|
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
|
||||||
|
// Health endpoint
|
||||||
|
healthHandler := health.Handler().WithJSON(true)
|
||||||
|
app.Get("/health", func(c *fiber.Ctx) error {
|
||||||
|
statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
|
||||||
|
return c.Status(statusCode).Send(body)
|
||||||
|
})
|
||||||
|
// Everything else falls back on static content
|
||||||
|
app.Use(redirect.New(redirect.Config{
|
||||||
|
Rules: map[string]string{
|
||||||
|
"/index.html": "/",
|
||||||
|
},
|
||||||
|
StatusCode: 301,
|
||||||
|
}))
|
||||||
|
staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
app.Use("/", fiberfs.New(fiberfs.Config{
|
||||||
|
Root: http.FS(staticFileSystem),
|
||||||
|
Index: "index.html",
|
||||||
|
Browse: true,
|
||||||
|
}))
|
||||||
|
//////////////////////
|
||||||
|
// PROTECTED ROUTES //
|
||||||
|
//////////////////////
|
||||||
|
// ORDER IS IMPORTANT: all routes applied AFTER the security middleware will require authn
|
||||||
|
protectedAPIRouter := apiRouter.Group("/")
|
||||||
|
if cfg.Security != nil {
|
||||||
|
if err := cfg.Security.RegisterHandlers(app); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
|
||||||
|
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus)
|
||||||
|
return app
|
||||||
|
}
|
||||||
121
api/api_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/ui"
|
||||||
|
"github.com/TwiN/gatus/v5/security"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
type Scenario struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
ExpectedCode int
|
||||||
|
Gzip bool
|
||||||
|
WithSecurity bool
|
||||||
|
}
|
||||||
|
scenarios := []Scenario{
|
||||||
|
{
|
||||||
|
Name: "health",
|
||||||
|
Path: "/health",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "metrics",
|
||||||
|
Path: "/metrics",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "favicon.ico",
|
||||||
|
Path: "/favicon.ico",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "app.js",
|
||||||
|
Path: "/js/app.js",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "app.js-gzipped",
|
||||||
|
Path: "/js/app.js",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
Gzip: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "chunk-vendors.js",
|
||||||
|
Path: "/js/chunk-vendors.js",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "chunk-vendors.js-gzipped",
|
||||||
|
Path: "/js/chunk-vendors.js",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
Gzip: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "index",
|
||||||
|
Path: "/",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "index-html-redirect",
|
||||||
|
Path: "/index.html",
|
||||||
|
ExpectedCode: fiber.StatusMovedPermanently,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "index-should-return-200-even-if-not-authenticated",
|
||||||
|
Path: "/",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
WithSecurity: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "endpoints-should-return-401-if-not-authenticated",
|
||||||
|
Path: "/api/v1/endpoints/statuses",
|
||||||
|
ExpectedCode: fiber.StatusUnauthorized,
|
||||||
|
WithSecurity: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "config-should-return-200-even-if-not-authenticated",
|
||||||
|
Path: "/api/v1/config",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
WithSecurity: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "config-should-always-return-200",
|
||||||
|
Path: "/api/v1/config",
|
||||||
|
ExpectedCode: fiber.StatusOK,
|
||||||
|
WithSecurity: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
cfg := &config.Config{Metrics: true, UI: &ui.Config{}}
|
||||||
|
if scenario.WithSecurity {
|
||||||
|
cfg.Security = &security.Config{
|
||||||
|
Basic: &security.BasicConfig{
|
||||||
|
Username: "john.doe",
|
||||||
|
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api := New(cfg)
|
||||||
|
router := api.Router()
|
||||||
|
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||||
|
if scenario.Gzip {
|
||||||
|
request.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
}
|
||||||
|
response, err := router.Test(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != scenario.ExpectedCode {
|
||||||
|
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
365
api/badge.go
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||||
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
|
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||||
|
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
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(c *fiber.Ctx) error {
|
||||||
|
duration := c.Params("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:
|
||||||
|
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
||||||
|
}
|
||||||
|
key := c.Params("key")
|
||||||
|
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
return c.Status(404).SendString(err.Error())
|
||||||
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
|
return c.Status(400).SendString(err.Error())
|
||||||
|
}
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
c.Set("Content-Type", "image/svg+xml")
|
||||||
|
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
c.Set("Expires", "0")
|
||||||
|
return c.Status(200).Send(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(cfg *config.Config) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
duration := c.Params("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:
|
||||||
|
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
||||||
|
}
|
||||||
|
key := c.Params("key")
|
||||||
|
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
return c.Status(404).SendString(err.Error())
|
||||||
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
|
return c.Status(400).SendString(err.Error())
|
||||||
|
}
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
c.Set("Content-Type", "image/svg+xml")
|
||||||
|
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
c.Set("Expires", "0")
|
||||||
|
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, cfg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
|
func HealthBadge(c *fiber.Ctx) error {
|
||||||
|
key := c.Params("key")
|
||||||
|
pagingConfig := paging.NewEndpointStatusParams()
|
||||||
|
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
return c.Status(404).SendString(err.Error())
|
||||||
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
|
return c.Status(400).SendString(err.Error())
|
||||||
|
}
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
healthStatus := HealthStatusUnknown
|
||||||
|
if len(status.Results) > 0 {
|
||||||
|
if status.Results[0].Success {
|
||||||
|
healthStatus = HealthStatusUp
|
||||||
|
} else {
|
||||||
|
healthStatus = HealthStatusDown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Set("Content-Type", "image/svg+xml")
|
||||||
|
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
c.Set("Expires", "0")
|
||||||
|
return c.Status(200).Send(generateHealthBadgeSVG(healthStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
func HealthBadgeShields(c *fiber.Ctx) error {
|
||||||
|
key := c.Params("key")
|
||||||
|
pagingConfig := paging.NewEndpointStatusParams()
|
||||||
|
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
return c.Status(404).SendString(err.Error())
|
||||||
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
|
return c.Status(400).SendString(err.Error())
|
||||||
|
}
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
healthStatus := HealthStatusUnknown
|
||||||
|
if len(status.Results) > 0 {
|
||||||
|
if status.Results[0].Success {
|
||||||
|
healthStatus = HealthStatusUp
|
||||||
|
} else {
|
||||||
|
healthStatus = HealthStatusDown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Set("Content-Type", "application/json")
|
||||||
|
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
c.Set("Expires", "0")
|
||||||
|
jsonData, err := generateHealthBadgeShields(healthStatus)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
return c.Status(200).Send(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
thresholds := ui.GetDefaultConfig().Badge.ResponseTime.Thresholds
|
||||||
|
if endpoint := cfg.GetEndpointByKey(key); endpoint != nil {
|
||||||
|
thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds
|
||||||
|
}
|
||||||
|
// the threshold config requires 5 values, so we can be sure it's set here
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if 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 generateHealthBadgeShields(healthStatus string) ([]byte, error) {
|
||||||
|
color := getBadgeShieldsColorFromHealth(healthStatus)
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"label": "gatus",
|
||||||
|
"message": healthStatus,
|
||||||
|
"color": color,
|
||||||
|
}
|
||||||
|
return json.Marshal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBadgeColorFromHealth(healthStatus string) string {
|
||||||
|
if healthStatus == HealthStatusUp {
|
||||||
|
return badgeColorHexAwesome
|
||||||
|
} else if healthStatus == HealthStatusDown {
|
||||||
|
return badgeColorHexVeryBad
|
||||||
|
}
|
||||||
|
return badgeColorHexPassable
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBadgeShieldsColorFromHealth(healthStatus string) string {
|
||||||
|
if healthStatus == HealthStatusUp {
|
||||||
|
return "brightgreen"
|
||||||
|
} else if healthStatus == HealthStatusDown {
|
||||||
|
return "red"
|
||||||
|
}
|
||||||
|
return "yellow"
|
||||||
|
}
|
||||||
395
api/badge_test.go
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||||
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
|
"github.com/TwiN/gatus/v5/watchdog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBadge(t *testing.T) {
|
||||||
|
defer store.Get().Clear()
|
||||||
|
defer cache.Clear()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Metrics: true,
|
||||||
|
Endpoints: []*endpoint.Endpoint{
|
||||||
|
{
|
||||||
|
Name: "frontend",
|
||||||
|
Group: "core",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "backend",
|
||||||
|
Group: "core",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||||
|
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||||
|
|
||||||
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||||
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||||
|
api := New(cfg)
|
||||||
|
router := api.Router()
|
||||||
|
type Scenario struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
ExpectedCode int
|
||||||
|
Gzip bool
|
||||||
|
}
|
||||||
|
scenarios := []Scenario{
|
||||||
|
{
|
||||||
|
Name: "badge-uptime-1h",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/uptimes/1h/badge.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-uptime-24h",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/uptimes/24h/badge.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-uptime-7d",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/uptimes/7d/badge.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-uptime-with-invalid-duration",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/uptimes/3d/badge.svg",
|
||||||
|
ExpectedCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-uptime-for-invalid-key",
|
||||||
|
Path: "/api/v1/endpoints/invalid_key/uptimes/7d/badge.svg",
|
||||||
|
ExpectedCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-response-time-1h",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/response-times/1h/badge.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-response-time-24h",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/response-times/24h/badge.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-response-time-7d",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/response-times/7d/badge.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-response-time-with-invalid-duration",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/response-times/3d/badge.svg",
|
||||||
|
ExpectedCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-response-time-for-invalid-key",
|
||||||
|
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
|
||||||
|
ExpectedCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-health-up",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/health/badge.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-health-down",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/health/badge.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-health-for-invalid-key",
|
||||||
|
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
|
||||||
|
ExpectedCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-shields-health-up",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/health/badge.shields",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-shields-health-down",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/health/badge.shields",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-shields-health-for-invalid-key",
|
||||||
|
Path: "/api/v1/endpoints/invalid_key/health/badge.shields",
|
||||||
|
ExpectedCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "chart-response-time-24h",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "chart-response-time-7d",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "chart-response-time-with-invalid-duration",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
|
||||||
|
ExpectedCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||||
|
if scenario.Gzip {
|
||||||
|
request.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
}
|
||||||
|
response, err := router.Test(request)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if response.StatusCode != scenario.ExpectedCode {
|
||||||
|
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
defer store.Get().Clear()
|
||||||
|
defer cache.Clear()
|
||||||
|
|
||||||
|
var (
|
||||||
|
firstCondition = endpoint.Condition("[STATUS] == 200")
|
||||||
|
secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
|
||||||
|
thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||||
|
)
|
||||||
|
|
||||||
|
firstTestEndpoint := endpoint.Endpoint{
|
||||||
|
Name: "a",
|
||||||
|
URL: "https://example.org/what/ever",
|
||||||
|
Method: "GET",
|
||||||
|
Body: "body",
|
||||||
|
Interval: 30 * time.Second,
|
||||||
|
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
|
||||||
|
Alerts: nil,
|
||||||
|
NumberOfFailuresInARow: 0,
|
||||||
|
NumberOfSuccessesInARow: 0,
|
||||||
|
UIConfig: ui.GetDefaultConfig(),
|
||||||
|
}
|
||||||
|
secondTestEndpoint := endpoint.Endpoint{
|
||||||
|
Name: "b",
|
||||||
|
URL: "https://example.org/what/ever",
|
||||||
|
Method: "GET",
|
||||||
|
Body: "body",
|
||||||
|
Interval: 30 * time.Second,
|
||||||
|
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
|
||||||
|
Alerts: nil,
|
||||||
|
NumberOfFailuresInARow: 0,
|
||||||
|
NumberOfSuccessesInARow: 0,
|
||||||
|
UIConfig: &ui.Config{
|
||||||
|
Badge: &ui.Badge{
|
||||||
|
ResponseTime: &ui.ResponseTime{
|
||||||
|
Thresholds: []int{100, 500, 1000, 2000, 3000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := &config.Config{
|
||||||
|
Metrics: true,
|
||||||
|
Endpoints: []*endpoint.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
|
||||||
|
}
|
||||||
|
|
||||||
|
testSuccessfulResult := endpoint.Result{
|
||||||
|
Hostname: "example.org",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
HTTPStatus: 200,
|
||||||
|
Errors: nil,
|
||||||
|
Connected: true,
|
||||||
|
Success: true,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Duration: 150 * time.Millisecond,
|
||||||
|
CertificateExpiration: 10 * time.Hour,
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{
|
||||||
|
Condition: "[STATUS] == 200",
|
||||||
|
Success: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: "[RESPONSE_TIME] < 500",
|
||||||
|
Success: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||||
|
Success: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Get().Insert(&firstTestEndpoint, &testSuccessfulResult)
|
||||||
|
store.Get().Insert(&secondTestEndpoint, &testSuccessfulResult)
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
Key string
|
||||||
|
ResponseTime int
|
||||||
|
ExpectedColor string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 10,
|
||||||
|
ExpectedColor: badgeColorHexAwesome,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 50,
|
||||||
|
ExpectedColor: badgeColorHexAwesome,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 75,
|
||||||
|
ExpectedColor: badgeColorHexGreat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 150,
|
||||||
|
ExpectedColor: badgeColorHexGreat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 201,
|
||||||
|
ExpectedColor: badgeColorHexGood,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 300,
|
||||||
|
ExpectedColor: badgeColorHexGood,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 301,
|
||||||
|
ExpectedColor: badgeColorHexPassable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 450,
|
||||||
|
ExpectedColor: badgeColorHexPassable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 700,
|
||||||
|
ExpectedColor: badgeColorHexBad,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: firstTestEndpoint.Key(),
|
||||||
|
ResponseTime: 1500,
|
||||||
|
ExpectedColor: badgeColorHexVeryBad,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: secondTestEndpoint.Key(),
|
||||||
|
ResponseTime: 50,
|
||||||
|
ExpectedColor: badgeColorHexAwesome,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: secondTestEndpoint.Key(),
|
||||||
|
ResponseTime: 1500,
|
||||||
|
ExpectedColor: badgeColorHexPassable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: secondTestEndpoint.Key(),
|
||||||
|
ResponseTime: 2222,
|
||||||
|
ExpectedColor: badgeColorHexBad,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Key+"-response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
|
||||||
|
if getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg) != scenario.ExpectedColor {
|
||||||
|
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBadgeColorFromHealth(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
HealthStatus string
|
||||||
|
ExpectedColor string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
HealthStatus: HealthStatusUp,
|
||||||
|
ExpectedColor: badgeColorHexAwesome,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
HealthStatus: HealthStatusDown,
|
||||||
|
ExpectedColor: badgeColorHexVeryBad,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
HealthStatus: HealthStatusUnknown,
|
||||||
|
ExpectedColor: badgeColorHexPassable,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run("health-"+scenario.HealthStatus, func(t *testing.T) {
|
||||||
|
if getBadgeColorFromHealth(scenario.HealthStatus) != scenario.ExpectedColor {
|
||||||
|
t.Errorf("expected %s from %s, got %v", scenario.ExpectedColor, scenario.HealthStatus, getBadgeColorFromHealth(scenario.HealthStatus))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
15
api/cache.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gocache/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cacheTTL = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
||||||
|
)
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
package handler
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
"github.com/TwiN/gatus/v3/storage/store/common"
|
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
@@ -29,9 +30,8 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
|
func ResponseTimeChart(c *fiber.Ctx) error {
|
||||||
vars := mux.Vars(r)
|
duration := c.Params("duration")
|
||||||
duration := vars["duration"]
|
|
||||||
var from time.Time
|
var from time.Time
|
||||||
switch duration {
|
switch duration {
|
||||||
case "7d":
|
case "7d":
|
||||||
@@ -39,23 +39,19 @@ func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
|
|||||||
case "24h":
|
case "24h":
|
||||||
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||||
default:
|
default:
|
||||||
http.Error(writer, "Durations supported: 7d, 24h", http.StatusBadRequest)
|
return c.Status(400).SendString("Durations supported: 7d, 24h")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now())
|
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == common.ErrEndpointNotFound {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
return c.Status(404).SendString(err.Error())
|
||||||
} else if err == common.ErrInvalidTimeRange {
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
return c.Status(400).SendString(err.Error())
|
||||||
} else {
|
|
||||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
return
|
return c.Status(500).SendString(err.Error())
|
||||||
}
|
}
|
||||||
if len(hourlyAverageResponseTime) == 0 {
|
if len(hourlyAverageResponseTime) == 0 {
|
||||||
http.Error(writer, "", http.StatusNoContent)
|
return c.Status(204).SendString("")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
series := chart.TimeSeries{
|
series := chart.TimeSeries{
|
||||||
Name: "Average response time per hour",
|
Name: "Average response time per hour",
|
||||||
@@ -111,12 +107,13 @@ func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
|
|||||||
},
|
},
|
||||||
Series: []chart.Series{series},
|
Series: []chart.Series{series},
|
||||||
}
|
}
|
||||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
c.Set("Content-Type", "image/svg+xml")
|
||||||
writer.Header().Set("Cache-Control", "no-cache, no-store")
|
c.Set("Cache-Control", "no-cache, no-store")
|
||||||
writer.Header().Set("Expires", "0")
|
c.Set("Expires", "0")
|
||||||
writer.WriteHeader(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
if err := graph.Render(chart.SVG, writer); err != nil {
|
if err := graph.Render(chart.SVG, c); err != nil {
|
||||||
log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error())
|
log.Println("[api.ResponseTimeChart] Failed to render response time chart:", err.Error())
|
||||||
return
|
return c.Status(500).SendString(err.Error())
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package handler
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v3/config"
|
"github.com/TwiN/gatus/v5/config"
|
||||||
"github.com/TwiN/gatus/v3/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v3/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
"github.com/TwiN/gatus/v3/watchdog"
|
"github.com/TwiN/gatus/v5/watchdog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResponseTimeChart(t *testing.T) {
|
func TestResponseTimeChart(t *testing.T) {
|
||||||
@@ -17,7 +17,7 @@ func TestResponseTimeChart(t *testing.T) {
|
|||||||
defer cache.Clear()
|
defer cache.Clear()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Metrics: true,
|
Metrics: true,
|
||||||
Endpoints: []*core.Endpoint{
|
Endpoints: []*endpoint.Endpoint{
|
||||||
{
|
{
|
||||||
Name: "frontend",
|
Name: "frontend",
|
||||||
Group: "core",
|
Group: "core",
|
||||||
@@ -28,9 +28,10 @@ func TestResponseTimeChart(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||||
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
|
api := New(cfg)
|
||||||
|
router := api.Router()
|
||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
Name string
|
Name string
|
||||||
Path string
|
Path string
|
||||||
@@ -58,22 +59,19 @@ func TestResponseTimeChart(t *testing.T) {
|
|||||||
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
|
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
|
||||||
ExpectedCode: http.StatusNotFound,
|
ExpectedCode: http.StatusNotFound,
|
||||||
},
|
},
|
||||||
{ // XXX: Remove this in v4.0.0
|
|
||||||
Name: "backward-compatible-services-chart-response-time-24h",
|
|
||||||
Path: "/api/v1/services/core_backend/response-times/24h/chart.svg",
|
|
||||||
ExpectedCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||||
if scenario.Gzip {
|
if scenario.Gzip {
|
||||||
request.Header.Set("Accept-Encoding", "gzip")
|
request.Header.Set("Accept-Encoding", "gzip")
|
||||||
}
|
}
|
||||||
responseRecorder := httptest.NewRecorder()
|
response, err := router.Test(request)
|
||||||
router.ServeHTTP(responseRecorder, request)
|
if err != nil {
|
||||||
if responseRecorder.Code != scenario.ExpectedCode {
|
return
|
||||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
}
|
||||||
|
if response.StatusCode != scenario.ExpectedCode {
|
||||||
|
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
25
api/config.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/security"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigHandler struct {
|
||||||
|
securityConfig *security.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||||
|
hasOIDC := false
|
||||||
|
isAuthenticated := true // Default to true if no security config is set
|
||||||
|
if handler.securityConfig != nil {
|
||||||
|
hasOIDC = handler.securityConfig.OIDC != nil
|
||||||
|
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
|
||||||
|
}
|
||||||
|
// Return the config
|
||||||
|
c.Set("Content-Type", "application/json")
|
||||||
|
return c.Status(200).
|
||||||
|
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
|
||||||
|
}
|
||||||