Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eb7763052 | ||
|
|
8c73ae6035 | ||
|
|
6954e9dde7 | ||
|
|
f6336eac4e | ||
|
|
0331c18401 | ||
|
|
1f8fd29dad | ||
|
|
5877c4b2be | ||
|
|
c6d0809ecc | ||
|
|
cf8a601104 | ||
|
|
96a0eebc0c | ||
|
|
2af3425b9e | ||
|
|
31bf2aeb80 | ||
|
|
787f6f0d74 | ||
|
|
17a431321c | ||
|
|
05e9add16d | ||
|
|
c4ef56511d | ||
|
|
cfa2c8ef6f | ||
|
|
f36b6863ce | ||
|
|
24482cf7a0 | ||
|
|
d661a0ea6d | ||
|
|
a0ec6941ab | ||
|
|
5e711fb3b9 | ||
|
|
ab66e7ec8a | ||
|
|
08aba6cd51 | ||
|
|
d3805cd77a | ||
|
|
dd70136e6c | ||
|
|
a94c480c22 | ||
|
|
10fd4ecd6b | ||
|
|
9287e2f9e2 | ||
|
|
257f859825 | ||
|
|
3a4ab62ddd | ||
|
|
a4e9d8e9b0 | ||
|
|
3be6d04d29 | ||
|
|
b59ff6f89e | ||
|
|
813fea93ee | ||
|
|
8f50e44b45 | ||
|
|
fb2448c15a | ||
|
|
db575aad13 | ||
|
|
6ed93d4b82 | ||
|
|
634123d723 | ||
|
|
75c25ac053 | ||
|
|
8088736d6e | ||
|
|
6c45f5b99c | ||
|
|
422eaa6d37 | ||
|
|
c423afb0bf | ||
|
|
835f768337 | ||
|
|
b3d0e54af2 | ||
|
|
1451cdfa64 | ||
|
|
53cc9d88e5 | ||
|
|
a6bc0039e9 | ||
|
|
adbc2c5ad7 | ||
|
|
154bc7dbc6 | ||
|
|
2d3fe9795f | ||
|
|
d19f564e4e | ||
|
|
babe7b0be9 | ||
|
|
dee04945d0 | ||
|
|
bf455fb7cc | ||
|
|
dfd2f7943f | ||
|
|
fece11540b | ||
|
|
ac43ef4ab7 | ||
|
|
bc25fea1c0 | ||
|
|
30cb7b6ec8 | ||
|
|
289d834587 | ||
|
|
428e415616 | ||
|
|
0d284c2494 | ||
|
|
4a46a5ae9e | ||
|
|
df3a2016ff | ||
|
|
dda83761b5 | ||
|
|
882444e0d5 | ||
|
|
fa4736c672 | ||
|
|
dc173b29bc | ||
|
|
c3a4ce1eb4 | ||
|
|
044f0454f8 | ||
|
|
9bd5c38a96 | ||
|
|
d6b4c2394a | ||
|
|
9fe4678193 | ||
|
|
f41560cd3e | ||
|
|
d7de795a9f | ||
|
|
f79e87844b | ||
|
|
c57a930bf3 | ||
|
|
d86afb2381 | ||
|
|
d69df41ef0 | ||
|
|
cbfdc359d3 | ||
|
|
f3822a949d | ||
|
|
db5fc8bc11 | ||
|
|
7a68920889 | ||
|
|
effad21c64 | ||
|
|
dafd547656 | ||
|
|
20487790ca | ||
|
|
b58094e10b | ||
|
|
bacf7d841b | ||
|
|
06ef7f9efe | ||
|
|
bfbe928173 | ||
|
|
7887ca66bc | ||
|
|
a917b31591 | ||
|
|
556f559221 | ||
|
|
670e35949e | ||
|
|
67642b130c | ||
|
|
7c9e2742c1 | ||
|
|
66e312b72f | ||
|
|
6e38114e27 | ||
|
|
9c99cc522d | ||
|
|
becc17202b | ||
|
|
c61b406483 | ||
|
|
44c36a8a5e | ||
|
|
cfa7b0ed51 | ||
|
|
ce433b57e0 | ||
|
|
d67c2ec251 | ||
|
|
74e7bdae8c | ||
|
|
8b0f432ffb | ||
|
|
2577b196be | ||
|
|
30b17f7bca | ||
|
|
a626b00b59 | ||
|
|
0e7f1d19f4 | ||
|
|
82d697b032 | ||
|
|
470e3a3ebc | ||
|
|
bab69478dd | ||
|
|
f28d1b61f0 | ||
|
|
75d8b40327 | ||
|
|
e8adc75afe | ||
|
|
6942f0f8e0 | ||
|
|
733760dc06 | ||
|
|
1a8452f375 | ||
|
|
1cbee5b732 |
@@ -1,6 +1,7 @@
|
||||
example
|
||||
.examples
|
||||
Dockerfile
|
||||
.github
|
||||
.idea
|
||||
.git
|
||||
web/app
|
||||
web/app
|
||||
*.db
|
||||
@@ -1,16 +1,16 @@
|
||||
metrics: true
|
||||
services:
|
||||
- name: TwiNNatioN
|
||||
url: https://twinnation.org/health
|
||||
endpoints:
|
||||
- name: website
|
||||
url: https://twin.sh/health
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- name: GitHub
|
||||
- name: github
|
||||
url: https://api.github.com/healthz
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- name: Example
|
||||
- name: example
|
||||
url: https://example.com/
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
@@ -1,12 +1,11 @@
|
||||
version: '3.7'
|
||||
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
container_name: gatus
|
||||
image: twinproduction/gatus
|
||||
restart: always
|
||||
ports:
|
||||
- 8080:8080
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
networks:
|
||||
@@ -18,7 +17,7 @@ services:
|
||||
restart: always
|
||||
command: --config.file=/etc/prometheus/prometheus.yml
|
||||
ports:
|
||||
- 9090:9090
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
networks:
|
||||
@@ -31,7 +30,7 @@ services:
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_PASSWORD: secret
|
||||
ports:
|
||||
- 3000:3000
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./grafana/grafana.ini/:/etc/grafana/grafana.ini:ro
|
||||
- ./grafana/provisioning/:/etc/grafana/provisioning/:ro
|
||||
@@ -58,9 +58,9 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(gatus_tasks[30s])) by (service)",
|
||||
"expr": "sum(rate(gatus_tasks[30s])) by (endpoint)",
|
||||
"interval": "30s",
|
||||
"legendFormat": "{{service}}",
|
||||
"legendFormat": "{{endpoint}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -145,9 +145,9 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(gatus_tasks{success=\"false\"}[30s])) by (service)",
|
||||
"expr": "sum(rate(gatus_tasks{success=\"false\"}[30s])) by (endpoint)",
|
||||
"interval": "30s",
|
||||
"legendFormat": "{{service}}",
|
||||
"legendFormat": "{{endpoint}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -232,10 +232,10 @@
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(gatus_tasks{success=\"true\"}[30s])) by (service)",
|
||||
"expr": "sum(rate(gatus_tasks{success=\"true\"}[30s])) by (endpoint)",
|
||||
"instant": false,
|
||||
"interval": "30s",
|
||||
"legendFormat": "{{service}}",
|
||||
"legendFormat": "{{endpoint}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -3,14 +3,14 @@ alerting:
|
||||
webhook-url: "http://mattermost:8065/hooks/tokengoeshere"
|
||||
insecure: true
|
||||
|
||||
services:
|
||||
endpoints:
|
||||
- name: example
|
||||
url: http://example.org
|
||||
url: https://example.org
|
||||
interval: 1m
|
||||
alerts:
|
||||
- type: mattermost
|
||||
enabled: true
|
||||
description: "healthcheck failed 3 times in a row"
|
||||
description: "health check failed 3 times in a row"
|
||||
send-on-resolved: true
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
@@ -1,11 +1,10 @@
|
||||
version: "3.8"
|
||||
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
container_name: gatus
|
||||
image: twinproduction/gatus:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
networks:
|
||||
@@ -15,7 +14,7 @@ services:
|
||||
container_name: mattermost
|
||||
image: mattermost/mattermost-preview:5.26.0
|
||||
ports:
|
||||
- 8065:8065
|
||||
- "8065:8065"
|
||||
networks:
|
||||
- default
|
||||
|
||||
42
.examples/docker-compose-postgres-storage/config.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
storage:
|
||||
type: postgres
|
||||
path: "postgres://username:password@postgres:5432/gatus?sslmode=disable"
|
||||
|
||||
endpoints:
|
||||
- name: back-end
|
||||
group: core
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[CERTIFICATE_EXPIRATION] > 48h"
|
||||
|
||||
- name: monitoring
|
||||
group: internal
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: nas
|
||||
group: internal
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: example-dns-query
|
||||
url: "8.8.8.8" # Address of the DNS server to use
|
||||
interval: 5m
|
||||
dns:
|
||||
query-name: "example.com"
|
||||
query-type: "A"
|
||||
conditions:
|
||||
- "[BODY] == 93.184.216.34"
|
||||
- "[DNS_RCODE] == NOERROR"
|
||||
|
||||
- name: icmp-ping
|
||||
url: "icmp://example.org"
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
29
.examples/docker-compose-postgres-storage/docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=gatus
|
||||
- POSTGRES_USER=username
|
||||
- POSTGRES_PASSWORD=password
|
||||
networks:
|
||||
- web
|
||||
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
networks:
|
||||
- web
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
networks:
|
||||
web:
|
||||
@@ -1,8 +1,8 @@
|
||||
storage:
|
||||
type: sqlite
|
||||
file: /data/data.db
|
||||
path: /data/data.db
|
||||
|
||||
services:
|
||||
endpoints:
|
||||
- name: back-end
|
||||
group: core
|
||||
url: "https://example.org/"
|
||||
@@ -1,9 +1,9 @@
|
||||
version: "3.8"
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config.yaml:/config/config.yaml
|
||||
- ./data:/data/
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
endpoints:
|
||||
- name: example
|
||||
url: http://example.org
|
||||
url: https://example.org
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
endpoints:
|
||||
- name: example
|
||||
url: http://example.org
|
||||
url: https://example.org
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
@@ -1,18 +1,25 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: gatus
|
||||
namespace: kube-system
|
||||
data:
|
||||
config.yaml: |
|
||||
metrics: true
|
||||
services:
|
||||
- name: TwiNNatioN
|
||||
url: https://twinnation.org/health
|
||||
interval: 1m
|
||||
endpoints:
|
||||
- name: website
|
||||
url: https://twin.sh/health
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- name: GitHub
|
||||
- "[BODY].status == UP"
|
||||
|
||||
- name: github
|
||||
url: https://api.github.com/healthz
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: cat-fact
|
||||
url: "https://cat-fact.herokuapp.com/facts/random"
|
||||
interval: 5m
|
||||
@@ -23,11 +30,14 @@ data:
|
||||
- "[BODY].text == pat(*cat*)"
|
||||
- "[STATUS] == pat(2*)"
|
||||
- "[CONNECTED] == true"
|
||||
- name: Example
|
||||
|
||||
- name: example
|
||||
url: https://example.com/
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
kind: ConfigMap
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: gatus
|
||||
namespace: kube-system
|
||||
@@ -41,14 +51,16 @@ spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
k8s-app: gatus
|
||||
app: gatus
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
k8s-app: gatus
|
||||
app: gatus
|
||||
name: gatus
|
||||
namespace: kube-system
|
||||
spec:
|
||||
serviceAccountName: gatus
|
||||
terminationGracePeriodSeconds: 5
|
||||
containers:
|
||||
- image: twinproduction/gatus
|
||||
imagePullPolicy: IfNotPresent
|
||||
@@ -84,4 +96,4 @@ spec:
|
||||
protocol: TCP
|
||||
targetPort: 8080
|
||||
selector:
|
||||
k8s-app: gatus
|
||||
app: gatus
|
||||
2
.gitattributes
vendored
@@ -1 +1 @@
|
||||
* text=lf
|
||||
* text=auto eol=lf
|
||||
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
||||
github: [TwinProduction]
|
||||
github: [TwiN]
|
||||
|
||||
BIN
.github/assets/dark-mode.png
vendored
|
Before Width: | Height: | Size: 38 KiB |
BIN
.github/assets/dashboard-conditions.png
vendored
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.github/assets/dashboard-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
BIN
.github/assets/example.png
vendored
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 43 KiB |
2
.github/assets/gatus-diagram.drawio
vendored
@@ -1 +1 @@
|
||||
<mxfile host="app.diagrams.net" modified="2021-07-30T04:27:17.723Z" agent="5.0 (Windows)" etag="hZKgW5ZLl0WUgYrCJXa4" version="14.9.2" type="device"><diagram id="1euQ5oT3BAcxlUCibzft" name="Page-1">7VvbcpswEP0aP6YDSPjy2MRum5lmptMkk+ZRARXUCtYV8oV+fUUsDLYSh07TLDP2k9FKWOKcw2q1EgNyka0/KjZPryDmchB48XpApoMg8IlHzE9lKTeWERlvDIkSsW3UGK7Fb26NnrUuRMyLnYYaQGox3zVGkOc80js2phSsdpt9B7nb65wl3DFcR0y61jsR63RjHYdeY//ERZLWPfuerclY3dgaipTFsGqZyGxALhSA3lxl6wsuK/BqXDb3fXimdjswxXPd5Yazebia3Z1ffuJfy+VwJm9K//ZsuPmXJZML+8B2sLqsEeCxAcQWQekUEsiZnDXWcwWLPOZVN54pNW0+A8yN0TfGH1zr0rLLFhqMKdWZtLWbPquOnn02aypgoSJ+4IFqjTCVcH2gXbhlwEiXQ8a1Ks19ikumxXJ3HMxqKNm2a2A2Fxbpv0Ddd1A3elNa5ImDfoNtBdQqFZpfz9kjBCvzxj2F49L8FV8fRtJ9cntDMLZyte+rX8t31ah/a0tbyh96/wksiqFIg5Yqv9n7Hwv3VeFdWBen63bltLSlV1Ry0FHJBFPJwfDEzkF2nnnb3oadeq7FYqch5L5d1x92yAj15XGmgRXTURoD/jSwDWPsNBCE2NMA8R1QjsPTkI5a3oRuaK7G0XKhQVXBNbaU9yMaMsaW8tjB6kilHXadRL2nGX6jaP1YY5yu9ExQ2Rmd2DnIDmqI409O7Bx2baiz9jY3daLnGXooalB1oucFelCTH5Sc6DlID0WN2+ixZg4704M699hRtlZBcwVLEXPl0PbCInF3RTn49yUjHfYtCT5ywLriRWGW1w9Cxeh4jfayRZNhN7iC/wXXxIHrWrLoZ++A8kNspOp91hZUX4ys1HShy/7BFaDD5eZsB8FQmm7PY7E0l0l1ecNZVtR200+rqn+YjtExdXOHV0xrrjIodO/wGo6w4aIOXDdc8kSxrHdgBT62uIJjTQ8F1lO9vG+AehQiONYEUXd+xqj8uBNetbGDv62zH6Pjb+sE7oqm+CUNDr1zyxTfLbt7YCLPeAaqf1EnGVNktAiyk9w9x+G9nZMko65OEtNHEnd1XnC1FBEvKiAqsxkF5Og+c/9UB35eg7rLz6koIuhhUqM+TIXmBag7Fd+shBTQO6gCGmJjhbxnj3byjXZN/uLunLihUgQGEpDyifQv9okhOkF3k8gHOdFWSbRrAEBRV7HUjQBuL9F1vD/d4+s4dKd7DGG/pkAnXQWKeg6nHmZboMUTrrZI2by6XGTyfWTW+QasSoIiMuiyBy6/QCEeQ1kyfQCtIWs1eC9FUlVo2JMyLLQUOb/YfqLlvZKfflne5HXUbYrNh1qPda3P3cjsDw==</diagram></mxfile>
|
||||
<mxfile host="app.diagrams.net" modified="2021-09-12T22:49:28.336Z" agent="5.0 (Windows)" etag="r9FJ6Bphqwq-LaTO-jp3" version="15.0.6" type="device"><diagram id="FBbfVOMCjf6Z2LK8Yagy" name="Page-1">7Vtdb5swFP01edwEOCHJY5t03aR1q9RWbR9d8MCb4TJj8rFfP9OYBOI2YVrTi5S8RPjaxOack2sf7PTIJFlcSprFVxAy0fOccNEj057nDceu/iwDy1WADEarQCR5uAq5m8AN/8NM0DHRgocsbzRUAELxrBkMIE1ZoBoxKiXMm81+gGj2mtGIWYGbgAo7es9DFZuo5zibis+MR3HV9dgzNQmtWptAHtMQ5rUQueiRiQRQq6tkMWGiBK8CZnXfp1dq1yOTLFVtbvj2/UGwZFq4GpXZfXYJ8Yh9MN8yo6IwT2wGq5YVBCzUiJgiSBVDBCkVF5vouYQiDVnZjaNLmzZfATIddHXwJ1NqaeilhQIdilUiTO2qz7KjV5/NhHIoZMB2PJAZv6IyYmrXgw/XFGjtMkiYkkt9o2SCKj5rDoQaFUXrdhuc9YWB+h9g9yzYteKk4mlkwb8Bt0RqHnPFbjL6jMFc/+ReAnKmv4otdkNpP7m5YTQ2ejW/WM8xg5039L+KxTXp+86BwCIYktRoyeWDuf+58FgWPg6q4nRRr5wuTekNpey3lPIIU8n9Ezk7yfEwyRngkrPh47Fe1x1yiIPJjm9NAnOqgjgE/Elg6DYnAUKwJ4HhkeaZUds842NKeWRJOVcgy7U1tpK3lzN9H1vJYwurI5W2O2yrbdQ0XaXCEz+vNhyj8uOe+NnDD6pDcE/5bV9+e2UKeyd+jtVft+eHoPJzrBa7PT+oJttFdtnd54fg5jf/xM8efnB/P2aYNUOUSZjxkEmLuD1+sWkue//vHl1n2LW34a7tta9Ynmuv/cRliI8Y2Xp15A5H7RDzDoaY7bhvBA1+dQ8rj2BjVX1xDatrLS05LdSyg3g56Hi9tIHqC93techn+jIqL28ZTfIqrvupVXUQVB8dVM9OcVQpJhPIVfcAc/tjbMCIBdgtEyySNOkeXN4YXV/H6qi81ruWuKcjjtVRtecHd8//WN+It+aHoL4R9+yd5XI7Dn8zzrJT+Ltxnu0+899CA9G9ibM/agnW4SZO23vyNGEJyA6ag/7AwYZrjJsnm+dvnPfLk8RpmSdRDy0Q2+vmTM54wPISiDKsRwEpetrcPo2D/xKK2LZ3yvMAZNi9PLBGBi0PVIen6vZszgWH7oFFqvkQDyzk7Ui0Q4uk3zJpoq79q1HWtByAhgSEeOF9PfZpr8EQPVMeq5clrb1SH1XPtle6+4Ku4+0ZvwM6tn0ShrDfUqBtj9YS3FWq7bnu8hdSbR7TrLwsEnEWaLevwSolyAONLn1i4hpy/ryaJdMnUAqSWoMzwaOyQsGWlKFQgqdssv53nfM2+vbJfn2Td5W3vUeYQa4iWXqBri3SBtWRrQMs0nRx85fE57raHzvJxV8=</diagram></mxfile>
|
||||
BIN
.github/assets/gatus-diagram.png
vendored
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
BIN
.github/assets/logo.png
vendored
Normal file
|
After Width: | Height: | Size: 51 KiB |
1
.github/codecov.yml
vendored
@@ -1,5 +1,6 @@
|
||||
ignore:
|
||||
- "watchdog/watchdog.go"
|
||||
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
||||
|
||||
coverage:
|
||||
status:
|
||||
|
||||
8
.github/workflows/build.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.16
|
||||
go-version: 1.17
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
- name: Build binary to make sure it works
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
- name: Test
|
||||
# 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
|
||||
# was configured by the "Set up Go 1.15" step (otherwise, it'd use sudo's "go" executable)
|
||||
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test -mod vendor ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v1.5.2
|
||||
uses: codecov/codecov-action@v2.1.0
|
||||
with:
|
||||
file: ./coverage.txt
|
||||
files: ./coverage.txt
|
||||
|
||||
2
.github/workflows/publish-latest.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
4
.github/workflows/publish-release.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Get the release
|
||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||
- name: Set up QEMU
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Build and push docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright 2021 TwiN
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -199,3 +199,4 @@
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
37
Makefile
@@ -1,14 +1,37 @@
|
||||
BINARY=gatus
|
||||
|
||||
install:
|
||||
go build -mod vendor -o $(BINARY) .
|
||||
|
||||
run:
|
||||
GATUS_CONFIG_FILE=./config.yaml ./$(BINARY)
|
||||
|
||||
clean:
|
||||
rm $(BINARY)
|
||||
|
||||
test:
|
||||
sudo go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
|
||||
|
||||
|
||||
##########
|
||||
# Docker #
|
||||
##########
|
||||
|
||||
docker-build:
|
||||
docker build -t twinproduction/gatus:latest .
|
||||
|
||||
docker-build-and-run:
|
||||
docker build -t twinproduction/gatus:latest . && docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
|
||||
docker-run:
|
||||
docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
|
||||
|
||||
build-frontend:
|
||||
docker-build-and-run: docker-build docker-run
|
||||
|
||||
|
||||
#############
|
||||
# Front end #
|
||||
#############
|
||||
|
||||
frontend-build:
|
||||
npm --prefix web/app run build
|
||||
|
||||
run-frontend:
|
||||
frontend-run:
|
||||
npm --prefix web/app run serve
|
||||
|
||||
test:
|
||||
go test ./alerting/... ./client/... ./config/... ./controller/... ./core/... ./jsonpath/... ./pattern/... ./security/... ./storage/... ./util/... ./watchdog/... -cover
|
||||
@@ -1,15 +1,15 @@
|
||||
package alert
|
||||
|
||||
// Alert is the service's alert configuration
|
||||
// Alert is a core.Endpoint's alert configuration
|
||||
type Alert struct {
|
||||
// Type of alert (required)
|
||||
Type Type `yaml:"type"`
|
||||
|
||||
// Enabled defines whether or not the alert is enabled
|
||||
// Enabled defines whether the alert is enabled
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
|
||||
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
||||
FailureThreshold int `yaml:"failure-threshold"`
|
||||
@@ -31,7 +31,7 @@ type Alert struct {
|
||||
|
||||
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
||||
// ongoing/triggered incidents
|
||||
ResolveKey string
|
||||
ResolveKey string `yaml:"-"`
|
||||
|
||||
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
|
||||
// should be set back to false. It is used to prevent the same alert from going out twice.
|
||||
@@ -41,7 +41,7 @@ type Alert struct {
|
||||
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
|
||||
// some reason, the alert provider always returns errors when trying to send the resolved notification
|
||||
// (SendOnResolved).
|
||||
Triggered bool
|
||||
Triggered bool `yaml:"-"`
|
||||
}
|
||||
|
||||
// GetDescription retrieves the description of the alert
|
||||
|
||||
@@ -11,6 +11,9 @@ const (
|
||||
// TypeDiscord is the Type for the discord alerting provider
|
||||
TypeDiscord Type = "discord"
|
||||
|
||||
// TypeEmail is the Type for the email alerting provider
|
||||
TypeEmail Type = "email"
|
||||
|
||||
// TypeMattermost is the Type for the mattermost alerting provider
|
||||
TypeMattermost Type = "mattermost"
|
||||
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/discord"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/teams"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/slack"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/teams"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
|
||||
)
|
||||
|
||||
// Config is the configuration for alerting providers
|
||||
type Config struct {
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom"`
|
||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||
|
||||
// Discord is the configuration for the discord alerting provider
|
||||
Discord *discord.AlertProvider `yaml:"discord"`
|
||||
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
|
||||
|
||||
// Email is the configuration for the email alerting provider
|
||||
Email *email.AlertProvider `yaml:"email,omitempty"`
|
||||
|
||||
// Mattermost is the configuration for the mattermost alerting provider
|
||||
Mattermost *mattermost.AlertProvider `yaml:"mattermost"`
|
||||
Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"`
|
||||
|
||||
// Messagebird is the configuration for the messagebird alerting provider
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird"`
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
|
||||
|
||||
// PagerDuty is the configuration for the pagerduty alerting provider
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty"`
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
|
||||
|
||||
// Slack is the configuration for the slack alerting provider
|
||||
Slack *slack.AlertProvider `yaml:"slack"`
|
||||
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
||||
|
||||
// Teams is the configuration for the teams alerting provider
|
||||
Teams *teams.AlertProvider `yaml:"teams"`
|
||||
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
|
||||
|
||||
// Telegram is the configuration for the telegram alerting provider
|
||||
Telegram *telegram.AlertProvider `yaml:"telegram"`
|
||||
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
|
||||
|
||||
// Twilio is the configuration for the twilio alerting provider
|
||||
Twilio *twilio.AlertProvider `yaml:"twilio"`
|
||||
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
||||
}
|
||||
|
||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
||||
@@ -59,6 +63,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
|
||||
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
|
||||
|
||||
@@ -2,17 +2,14 @@ package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
||||
@@ -20,7 +17,6 @@ import (
|
||||
type AlertProvider struct {
|
||||
URL string `yaml:"url"`
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"` // deprecated
|
||||
Body string `yaml:"body,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
|
||||
@@ -28,7 +24,7 @@ type AlertProvider struct {
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
@@ -36,20 +32,10 @@ type AlertProvider struct {
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
// XXX: remove the next 4 lines in v3.0.0
|
||||
if provider.Insecure {
|
||||
log.Println("WARNING: alerting.*.insecure has been deprecated and will be removed in v3.0.0 in favor of alerting.*.client.insecure")
|
||||
provider.ClientConfig.Insecure = true
|
||||
}
|
||||
}
|
||||
return len(provider.URL) > 0 && provider.ClientConfig != nil
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *AlertProvider {
|
||||
return provider
|
||||
}
|
||||
|
||||
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
||||
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
|
||||
status := "TRIGGERED"
|
||||
@@ -64,7 +50,7 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
|
||||
return status
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription string, resolved bool) *http.Request {
|
||||
func (provider *AlertProvider) buildHTTPRequest(endpointName, alertDescription string, resolved bool) *http.Request {
|
||||
body := provider.Body
|
||||
providerURL := provider.URL
|
||||
method := provider.Method
|
||||
@@ -72,8 +58,11 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
|
||||
if strings.Contains(body, "[ALERT_DESCRIPTION]") {
|
||||
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alertDescription)
|
||||
}
|
||||
if strings.Contains(body, "[SERVICE_NAME]") {
|
||||
body = strings.ReplaceAll(body, "[SERVICE_NAME]", serviceName)
|
||||
if strings.Contains(body, "[SERVICE_NAME]") { // XXX: Remove this in v4.0.0
|
||||
body = strings.ReplaceAll(body, "[SERVICE_NAME]", endpointName)
|
||||
}
|
||||
if strings.Contains(body, "[ENDPOINT_NAME]") {
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpointName)
|
||||
}
|
||||
if strings.Contains(body, "[ALERT_TRIGGERED_OR_RESOLVED]") {
|
||||
if resolved {
|
||||
@@ -85,8 +74,11 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
|
||||
if strings.Contains(providerURL, "[ALERT_DESCRIPTION]") {
|
||||
providerURL = strings.ReplaceAll(providerURL, "[ALERT_DESCRIPTION]", alertDescription)
|
||||
}
|
||||
if strings.Contains(providerURL, "[SERVICE_NAME]") {
|
||||
providerURL = strings.ReplaceAll(providerURL, "[SERVICE_NAME]", serviceName)
|
||||
if strings.Contains(providerURL, "[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 {
|
||||
@@ -106,27 +98,17 @@ func (provider *AlertProvider) buildHTTPRequest(serviceName, alertDescription st
|
||||
return request
|
||||
}
|
||||
|
||||
// Send a request to the alert provider and return the body
|
||||
func (provider *AlertProvider) Send(serviceName, alertDescription string, resolved bool) ([]byte, error) {
|
||||
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
|
||||
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
request := provider.buildHTTPRequest(serviceName, alertDescription, resolved)
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
request := provider.buildHTTPRequest(endpoint.Name, alert.GetDescription(), resolved)
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("call to provider alert returned status code %d", response.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return ioutil.ReadAll(response.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -2,10 +2,13 @@ package custom
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -13,23 +16,100 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{URL: "http://example.com"}
|
||||
validProvider := AlertProvider{URL: "https://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "http://example.com/service-name?event=RESOLVED&description=alert-description"
|
||||
ExpectedBody = "service-name,alert-description,RESOLVED"
|
||||
ExpectedURL = "https://example.com/endpoint-name?event=RESOLVED&description=alert-description"
|
||||
ExpectedBody = "endpoint-name,alert-description,RESOLVED"
|
||||
)
|
||||
customAlertProvider := &AlertProvider{
|
||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
}
|
||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
|
||||
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
@@ -41,15 +121,15 @@ func TestAlertProvider_buildHTTPRequestWhenResolved(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "http://example.com/service-name?event=TRIGGERED&description=alert-description"
|
||||
ExpectedBody = "service-name,alert-description,TRIGGERED"
|
||||
ExpectedURL = "https://example.com/endpoint-name?event=TRIGGERED&description=alert-description"
|
||||
ExpectedBody = "endpoint-name,alert-description,TRIGGERED"
|
||||
)
|
||||
customAlertProvider := &AlertProvider{
|
||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: map[string]string{"Authorization": "Basic hunter2"},
|
||||
}
|
||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", false)
|
||||
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", false)
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
@@ -59,25 +139,14 @@ func TestAlertProvider_buildHTTPRequestWhenTriggered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProvider(t *testing.T) {
|
||||
provider := AlertProvider{URL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Error("expected URL to be http://example.com, got", customAlertProvider.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
const (
|
||||
ExpectedURL = "http://example.com/service-name?event=test&description=alert-description"
|
||||
ExpectedBody = "service-name,alert-description,test"
|
||||
ExpectedURL = "https://example.com/endpoint-name?event=test&description=alert-description"
|
||||
ExpectedBody = "endpoint-name,alert-description,test"
|
||||
)
|
||||
customAlertProvider := &AlertProvider{
|
||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
Placeholders: map[string]map[string]string{
|
||||
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||
@@ -85,7 +154,7 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
request := customAlertProvider.buildHTTPRequest("service-name", "alert-description", true)
|
||||
request := customAlertProvider.buildHTTPRequest("endpoint-name", "alert-description", true)
|
||||
if request.URL.String() != ExpectedURL {
|
||||
t.Error("expected URL to be", ExpectedURL, "was", request.URL.String())
|
||||
}
|
||||
@@ -97,8 +166,8 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||
customAlertProvider := &AlertProvider{
|
||||
URL: "http://example.com/[SERVICE_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[SERVICE_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[ENDPOINT_NAME],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
Placeholders: nil,
|
||||
}
|
||||
@@ -109,3 +178,35 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
@@ -22,15 +24,34 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, results string
|
||||
var colorCode int
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
||||
colorCode = 3066993
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
|
||||
colorCode = 15158332
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
@@ -46,10 +67,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"content": "",
|
||||
"embeds": [
|
||||
{
|
||||
@@ -65,9 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, description, colorCode, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}`, message, description, colorCode, results)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package discord
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
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,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for **svc** has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["embeds"].([]interface{})[0].(map[string]interface{})["description"] {
|
||||
t.Errorf("expected $.embeds[0].description to be %s, got %s", expected, body["embeds"].([]interface{})[0].(map[string]interface{})["description"])
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"color\": 15158332,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"content\": \"\",\n \"embeds\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"description\": \"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"color\": 3066993,\n \"fields\": [\n {\n \"name\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"inline\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
71
alerting/provider/email/email.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
gomail "gopkg.in/mail.v2"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
||||
type AlertProvider struct {
|
||||
From string `yaml:"from"`
|
||||
Password string `yaml:"password"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
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
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", provider.From)
|
||||
m.SetHeader("To", strings.Split(provider.To, ",")...)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/plain", body)
|
||||
d := gomail.NewDialer(provider.Host, provider.Port, provider.From, provider.Password)
|
||||
return d.DialAndSend(m)
|
||||
}
|
||||
|
||||
// buildMessageSubjectAndBody builds the message subject and body
|
||||
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
|
||||
var subject, message, results string
|
||||
if resolved {
|
||||
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.Name)
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
||||
} else {
|
||||
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.Name)
|
||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n\nAlert description: " + alertDescription
|
||||
}
|
||||
return subject, message + description + "\n\nCondition results:\n" + results
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
79
alerting/provider/email/email_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedSubject string
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if subject != scenario.ExpectedSubject {
|
||||
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
|
||||
}
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
package mattermost
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/client"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Insecure bool `yaml:"insecure,omitempty"` // deprecated
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
@@ -27,24 +26,37 @@ type AlertProvider struct {
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
// XXX: remove the next 3 lines in v3.0.0
|
||||
if provider.Insecure {
|
||||
log.Println("WARNING: alerting.mattermost.insecure has been deprecated and will be removed in v3.0.0 in favor of alerting.mattermost.client.insecure")
|
||||
provider.ClientConfig.Insecure = true
|
||||
}
|
||||
}
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
var color string
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
||||
color = "#36A64F"
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
var results string
|
||||
@@ -61,14 +73,10 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
ClientConfig: provider.ClientConfig,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"text": "",
|
||||
"username": "gatus",
|
||||
"icon_url": "https://raw.githubusercontent.com/TwinProduction/gatus/master/static/logo.png",
|
||||
"icon_url": "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||
"attachments": [
|
||||
{
|
||||
"title": ":rescue_worker_helmet: Gatus",
|
||||
@@ -90,9 +98,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, message, description, color, service.URL, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}`, message, message, description, color, endpoint.URL, results)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package mattermost
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
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,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
|
||||
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"short\": false,\n \"color\": \"#DD0000\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"username\": \"gatus\",\n \"icon_url\": \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n \"attachments\": [\n {\n \"title\": \":rescue_worker_helmet: Gatus\",\n \"fallback\": \"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"short\": false,\n \"color\": \"#36A64F\",\n \"fields\": [\n {\n \"title\": \"URL\",\n \"value\": \"\",\n \"short\": false\n },\n {\n \"title\": \"Condition results\",\n \"value\": \":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\n \"short\": false\n }\n ]\n }\n ]\n}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package messagebird
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,8 +21,8 @@ type AlertProvider struct {
|
||||
Originator string `yaml:"originator"`
|
||||
Recipients string `yaml:"recipients"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
// 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
|
||||
@@ -28,29 +30,40 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
// Reference doc for messagebird https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
// 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 {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
}
|
||||
|
||||
return &custom.AlertProvider{
|
||||
URL: restAPIURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"originator": "%s",
|
||||
"recipients": "%s",
|
||||
"body": "%s"
|
||||
}`, provider.Originator, provider.Recipients, message),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": fmt.Sprintf("AccessKey %s", provider.AccessKey),
|
||||
},
|
||||
}
|
||||
}`, provider.Originator, provider.Recipients, message)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package messagebird
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -25,54 +26,137 @@ func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
AccessKey: "1",
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
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,
|
||||
},
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
AccessKey: "1",
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
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{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"originator\": \"2\",\n \"recipients\": \"3\",\n \"body\": \"TRIGGERED: endpoint-name - description-1\"\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"originator\": \"5\",\n \"recipients\": \"6\",\n \"body\": \"RESOLVED: endpoint-name - description-2\"\n}",
|
||||
},
|
||||
}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://rest.messagebird.com/messages" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://rest.messagebird.com/messages", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -17,33 +21,84 @@ const (
|
||||
type AlertProvider struct {
|
||||
IntegrationKey string `yaml:"integration-key"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
// 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"`
|
||||
IntegrationKey string `yaml:"integration-key"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.IntegrationKey) == 32
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 {
|
||||
return false
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
// Either the default integration key has the right length, or there are overrides who are properly configured.
|
||||
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
// Send an alert using the provider
|
||||
//
|
||||
// relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// 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 {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
if alert.IsSendingOnResolved() {
|
||||
if resolved {
|
||||
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
|
||||
alert.ResolveKey = ""
|
||||
} else {
|
||||
// We need to retrieve the resolve key from the response
|
||||
body, err := ioutil.ReadAll(response.Body)
|
||||
var payload pagerDutyResponsePayload
|
||||
if err = json.Unmarshal(body, &payload); err != nil {
|
||||
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
||||
log.Printf("[pagerduty][Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
|
||||
} else {
|
||||
alert.ResolveKey = payload.DedupKey
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, eventAction, resolveKey string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
eventAction = "resolve"
|
||||
resolveKey = alert.ResolveKey
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
eventAction = "trigger"
|
||||
resolveKey = ""
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: restAPIURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"routing_key": "%s",
|
||||
"dedup_key": "%s",
|
||||
"event_action": "%s",
|
||||
@@ -52,14 +107,28 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
"source": "%s",
|
||||
"severity": "critical"
|
||||
}
|
||||
}`, provider.IntegrationKey, resolveKey, eventAction, message, service.Name),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}`, provider.getIntegrationKeyForGroup(endpoint.Group), resolveKey, eventAction, message, endpoint.Name)
|
||||
}
|
||||
|
||||
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
|
||||
func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return override.IntegrationKey
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider.IntegrationKey
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
type pagerDutyResponsePayload struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
DedupKey string `json:"dedup_key"`
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ package pagerduty
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -21,46 +22,225 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
IntegrationKey: "00000000000000000000000000000000",
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
providerWithInvalidOverrideIntegrationKey := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
IntegrationKey: "",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
if providerWithInvalidOverrideIntegrationKey.IsValid() {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
providerWithValidOverride := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
IntegrationKey: "00000000000000000000000000000000",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
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,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://events.pagerduty.com/v2/enqueue" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://events.pagerduty.com/v2/enqueue", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
description := "test"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||
Alert: alert.Alert{Description: &description},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"\",\n \"event_action\": \"trigger\",\n \"payload\": {\n \"summary\": \"TRIGGERED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"routing_key\": \"00000000000000000000000000000000\",\n \"dedup_key\": \"key\",\n \"event_action\": \"resolve\",\n \"payload\": {\n \"summary\": \"RESOLVED: - test\",\n \"source\": \"\",\n \"severity\": \"critical\"\n }\n}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(&core.Endpoint{}, &scenario.Alert, &core.Result{}, scenario.Resolved)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
IntegrationKey: "00000000000000000000000000000001",
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "00000000000000000000000000000001",
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
IntegrationKey: "00000000000000000000000000000001",
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "00000000000000000000000000000001",
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
IntegrationKey: "00000000000000000000000000000001",
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
IntegrationKey: "00000000000000000000000000000002",
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "00000000000000000000000000000001",
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
IntegrationKey: "00000000000000000000000000000001",
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
IntegrationKey: "00000000000000000000000000000002",
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "00000000000000000000000000000002",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/discord"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/mattermost"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/messagebird"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/pagerduty"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/slack"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/teams"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/telegram"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/twilio"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/slack"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/teams"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the interface that each providers should implement
|
||||
@@ -19,32 +20,32 @@ type AlertProvider interface {
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
IsValid() bool
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
GetDefaultAlert() *alert.Alert
|
||||
|
||||
// Send an alert using the provider
|
||||
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error
|
||||
}
|
||||
|
||||
// ParseWithDefaultAlert parses a service alert by using the provider's default alert as a baseline
|
||||
func ParseWithDefaultAlert(providerDefaultAlert, serviceAlert *alert.Alert) {
|
||||
if providerDefaultAlert == nil || serviceAlert == nil {
|
||||
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
|
||||
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
||||
if providerDefaultAlert == nil || endpointAlert == nil {
|
||||
return
|
||||
}
|
||||
if serviceAlert.Enabled == nil {
|
||||
serviceAlert.Enabled = providerDefaultAlert.Enabled
|
||||
if endpointAlert.Enabled == nil {
|
||||
endpointAlert.Enabled = providerDefaultAlert.Enabled
|
||||
}
|
||||
if serviceAlert.SendOnResolved == nil {
|
||||
serviceAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
|
||||
if endpointAlert.SendOnResolved == nil {
|
||||
endpointAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
|
||||
}
|
||||
if serviceAlert.Description == nil {
|
||||
serviceAlert.Description = providerDefaultAlert.Description
|
||||
if endpointAlert.Description == nil {
|
||||
endpointAlert.Description = providerDefaultAlert.Description
|
||||
}
|
||||
if serviceAlert.FailureThreshold == 0 {
|
||||
serviceAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
|
||||
if endpointAlert.FailureThreshold == 0 {
|
||||
endpointAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
|
||||
}
|
||||
if serviceAlert.SuccessThreshold == 0 {
|
||||
serviceAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
||||
if endpointAlert.SuccessThreshold == 0 {
|
||||
endpointAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ var (
|
||||
// Validate interface implementation on compile
|
||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||
|
||||
@@ -3,13 +3,13 @@ package provider
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
)
|
||||
|
||||
func TestParseWithDefaultAlert(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
DefaultAlert, ServiceAlert, ExpectedOutputAlert *alert.Alert
|
||||
Name string
|
||||
DefaultAlert, EndpointAlert, ExpectedOutputAlert *alert.Alert
|
||||
}
|
||||
enabled := true
|
||||
disabled := false
|
||||
@@ -17,7 +17,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
secondDescription := "description-2"
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "service-alert-type-only",
|
||||
Name: "endpoint-alert-type-only",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
@@ -25,7 +25,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
EndpointAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
@@ -38,7 +38,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "service-alert-overwrites-default-alert",
|
||||
Name: "endpoint-alert-overwrites-default-alert",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Enabled: &disabled,
|
||||
SendOnResolved: &disabled,
|
||||
@@ -46,7 +46,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
EndpointAlert: &alert.Alert{
|
||||
Type: alert.TypeTelegram,
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
@@ -64,7 +64,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "service-alert-partially-overwrites-default-alert",
|
||||
Name: "endpoint-alert-partially-overwrites-default-alert",
|
||||
DefaultAlert: &alert.Alert{
|
||||
Enabled: &enabled,
|
||||
SendOnResolved: &enabled,
|
||||
@@ -72,7 +72,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
EndpointAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
Enabled: nil,
|
||||
SendOnResolved: nil,
|
||||
@@ -98,7 +98,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
},
|
||||
ServiceAlert: &alert.Alert{
|
||||
EndpointAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
},
|
||||
ExpectedOutputAlert: &alert.Alert{
|
||||
@@ -120,33 +120,33 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
FailureThreshold: 2,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
ServiceAlert: nil,
|
||||
EndpointAlert: nil,
|
||||
ExpectedOutputAlert: nil,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.ServiceAlert)
|
||||
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert)
|
||||
if scenario.ExpectedOutputAlert == nil {
|
||||
if scenario.ServiceAlert != nil {
|
||||
if scenario.EndpointAlert != nil {
|
||||
t.Fail()
|
||||
}
|
||||
return
|
||||
}
|
||||
if scenario.ServiceAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
|
||||
t.Errorf("expected ServiceAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.ServiceAlert.IsEnabled())
|
||||
if scenario.EndpointAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
|
||||
t.Errorf("expected EndpointAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.EndpointAlert.IsEnabled())
|
||||
}
|
||||
if scenario.ServiceAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
|
||||
t.Errorf("expected ServiceAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.ServiceAlert.IsSendingOnResolved())
|
||||
if scenario.EndpointAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
|
||||
t.Errorf("expected EndpointAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.EndpointAlert.IsSendingOnResolved())
|
||||
}
|
||||
if scenario.ServiceAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
|
||||
t.Errorf("expected ServiceAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.ServiceAlert.GetDescription())
|
||||
if scenario.EndpointAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
|
||||
t.Errorf("expected EndpointAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.EndpointAlert.GetDescription())
|
||||
}
|
||||
if scenario.ServiceAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
|
||||
t.Errorf("expected ServiceAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.ServiceAlert.FailureThreshold)
|
||||
if scenario.EndpointAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
|
||||
t.Errorf("expected EndpointAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.EndpointAlert.FailureThreshold)
|
||||
}
|
||||
if scenario.ServiceAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
||||
t.Errorf("expected ServiceAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.ServiceAlert.SuccessThreshold)
|
||||
if scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
||||
t.Errorf("expected EndpointAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
package slack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
@@ -22,14 +24,33 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, color, results string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
||||
color = "#36A64F"
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
@@ -45,10 +66,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"text": "",
|
||||
"attachments": [
|
||||
{
|
||||
@@ -65,9 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
]
|
||||
}
|
||||
]
|
||||
}`, message, description, color, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}`, message, description, color, results)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package slack
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
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,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["attachments"].([]interface{})[0].(map[string]interface{})["text"] {
|
||||
t.Errorf("expected $.attachments[0].description to be %s, got %s", expected, body["attachments"].([]interface{})[0].(map[string]interface{})["text"])
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"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}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"text\": \"\",\n \"attachments\": [\n {\n \"title\": \":helmet_with_white_cross: Gatus\",\n \"text\": \"An alert for *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}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.com" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.com", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
package teams
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Teams
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
@@ -22,15 +24,33 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.WebhookURL) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
var message string
|
||||
var color string
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold)
|
||||
color = "#36A64F"
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", service.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
var results string
|
||||
@@ -47,10 +67,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\\n> " + alertDescription
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: provider.WebhookURL,
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{
|
||||
return fmt.Sprintf(`{
|
||||
"@type": "MessageCard",
|
||||
"@context": "http://schema.org/extensions",
|
||||
"themeColor": "%s",
|
||||
@@ -66,9 +83,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
"text": "%s"
|
||||
}
|
||||
]
|
||||
}`, color, message, description, service.URL, results),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
}`, color, message, description, endpoint.URL, results)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -3,11 +3,12 @@ package teams
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -21,50 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
alertDescription := "test"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "svc"}, &alert.Alert{Description: &alertDescription}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
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,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
if expected := "An alert for *svc* has been resolved after passing successfully 0 time(s) in a row:\n> test"; expected != body["text"] {
|
||||
t.Errorf("expected $.text to be %s, got %s", expected, body["text"])
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{WebhookURL: "http://example.org"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#DD0000\",\n \"title\": \"🚨 Gatus\",\n \"text\": \"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n> description-1\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"❌ - `[CONNECTED] == true`<br/>❌ - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"@type\": \"MessageCard\",\n \"@context\": \"http://schema.org/extensions\",\n \"themeColor\": \"#36A64F\",\n \"title\": \"🚨 Gatus\",\n \"text\": \"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row:\\n> description-2\",\n \"sections\": [\n {\n \"activityTitle\": \"URL\",\n \"text\": \"\"\n },\n {\n \"activityTitle\": \"Condition results\",\n \"text\": \"✅ - `[CONNECTED] == true`<br/>✅ - `[STATUS] == 200`<br/>\"\n }\n ]\n}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != "http://example.org" {
|
||||
t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Telegram
|
||||
@@ -14,7 +16,7 @@ type AlertProvider struct {
|
||||
Token string `yaml:"token"`
|
||||
ID string `yaml:"id"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
@@ -23,13 +25,32 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.Token) > 0 && len(provider.ID) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", 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
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, results string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", endpoint.Name, alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
@@ -46,12 +67,7 @@ func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, aler
|
||||
} else {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results)
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token),
|
||||
Method: http.MethodPost,
|
||||
Body: fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text),
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
return fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN"}`, provider.ID, text)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -2,13 +2,13 @@ package telegram
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -22,70 +22,137 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
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,
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "resolved") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring resolved")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
//_, err := json.Marshal(customAlertProvider.Body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
||||
description := "Healthcheck Successful"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{Description: &description}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
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{ID: "123"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{ID: "123"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\": \"123\", \"text\": \"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\", \"parse_mode\": \"MARKDOWN\"}",
|
||||
},
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithDescription(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"}
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "triggered") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring triggered")
|
||||
}
|
||||
if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) {
|
||||
t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
err := json.Unmarshal([]byte(customAlertProvider.Body), &body)
|
||||
if err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider/custom"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/client"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
||||
@@ -18,7 +20,7 @@ type AlertProvider struct {
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert"`
|
||||
}
|
||||
|
||||
@@ -27,27 +29,39 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
|
||||
}
|
||||
|
||||
// ToCustomAlertProvider converts the provider into a custom.AlertProvider
|
||||
func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, _ *core.Result, resolved bool) *custom.AlertProvider {
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token))))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", service.Name, alert.GetDescription())
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", service.Name, alert.GetDescription())
|
||||
}
|
||||
return &custom.AlertProvider{
|
||||
URL: fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID),
|
||||
Method: http.MethodPost,
|
||||
Body: url.Values{
|
||||
"To": {provider.To},
|
||||
"From": {provider.From},
|
||||
"Body": {message},
|
||||
}.Encode(),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", provider.SID, provider.Token)))),
|
||||
},
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
}
|
||||
return url.Values{
|
||||
"To": {provider.To},
|
||||
"From": {provider.From},
|
||||
"Body": {message},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||
@@ -25,54 +23,56 @@ func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
SID: "1",
|
||||
Token: "2",
|
||||
From: "3",
|
||||
To: "4",
|
||||
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{SID: "1", Token: "2", From: "3", To: "4"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
|
||||
},
|
||||
}
|
||||
description := "alert-description"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, true)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "RESOLVED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring RESOLVED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/1/Messages.json", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
if customAlertProvider.Body != "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4" {
|
||||
t.Errorf("expected body to be %s, got %s", "Body=RESOLVED%3A+service-name+-+alert-description&From=3&To=4", customAlertProvider.Body)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
SID: "4",
|
||||
Token: "3",
|
||||
From: "2",
|
||||
To: "1",
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
description := "alert-description"
|
||||
customAlertProvider := provider.ToCustomAlertProvider(&core.Service{Name: "service-name"}, &alert.Alert{Description: &description}, &core.Result{}, false)
|
||||
if customAlertProvider == nil {
|
||||
t.Fatal("customAlertProvider shouldn't have been nil")
|
||||
}
|
||||
if !strings.Contains(customAlertProvider.Body, "TRIGGERED") {
|
||||
t.Error("customAlertProvider.Body should've contained the substring TRIGGERED")
|
||||
}
|
||||
if customAlertProvider.URL != "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json" {
|
||||
t.Errorf("expected URL to be %s, got %s", "https://api.twilio.com/2010-04-01/Accounts/4/Messages.json", customAlertProvider.URL)
|
||||
}
|
||||
if customAlertProvider.Method != http.MethodPost {
|
||||
t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method)
|
||||
}
|
||||
if customAlertProvider.Body != "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1" {
|
||||
t.Errorf("expected body to be %s, got %s", "Body=TRIGGERED%3A+service-name+-+alert-description&From=2&To=1", customAlertProvider.Body)
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,21 @@ import (
|
||||
"github.com/go-ping/ping"
|
||||
)
|
||||
|
||||
// injectedHTTPClient is used for testing purposes
|
||||
var injectedHTTPClient *http.Client
|
||||
|
||||
// GetHTTPClient returns the shared HTTP client
|
||||
func GetHTTPClient(config *Config) *http.Client {
|
||||
if injectedHTTPClient != nil {
|
||||
return injectedHTTPClient
|
||||
}
|
||||
if config == nil {
|
||||
return defaultConfig.getHTTPClient()
|
||||
}
|
||||
return config.getHTTPClient()
|
||||
}
|
||||
|
||||
// CanCreateTCPConnection checks whether a connection can be established with a TCP service
|
||||
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
|
||||
func CanCreateTCPConnection(address string, config *Config) bool {
|
||||
conn, err := net.DialTimeout("tcp", address, config.Timeout)
|
||||
if err != nil {
|
||||
@@ -38,7 +44,11 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
|
||||
if len(hostAndPort) != 2 {
|
||||
return false, nil, errors.New("invalid address for starttls, format must be host:port")
|
||||
}
|
||||
smtpClient, err := smtp.Dial(address)
|
||||
connection, err := net.DialTimeout("tcp", address, config.Timeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
smtpClient, err := smtp.NewClient(connection, hostAndPort[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -57,6 +67,20 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
|
||||
return true, certificate, nil
|
||||
}
|
||||
|
||||
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
|
||||
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
|
||||
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
verifiedChains := connection.ConnectionState().VerifiedChains
|
||||
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
|
||||
return
|
||||
}
|
||||
return true, verifiedChains[0][0], nil
|
||||
}
|
||||
|
||||
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
||||
//
|
||||
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
|
||||
@@ -67,8 +91,11 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
}
|
||||
pinger.Count = 1
|
||||
pinger.Timeout = config.Timeout
|
||||
// Set the pinger's privileged mode to true for every operating system except darwin
|
||||
// https://github.com/TwinProduction/gatus/issues/132
|
||||
// Set the pinger's privileged mode to true for every GOOS except darwin
|
||||
// See https://github.com/TwiN/gatus/issues/132
|
||||
//
|
||||
// Note that for this to work on Linux, Gatus must run with sudo privileges.
|
||||
// See https://github.com/go-ping/ping#linux
|
||||
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
||||
err = pinger.Run()
|
||||
if err != nil {
|
||||
@@ -83,3 +110,8 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
|
||||
func InjectHTTPClient(httpClient *http.Client) {
|
||||
injectedHTTPClient = httpClient
|
||||
}
|
||||
|
||||
@@ -91,6 +91,56 @@ func TestCanPerformStartTLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanPerformTLS(t *testing.T) {
|
||||
type args struct {
|
||||
address string
|
||||
insecure bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantConnected bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "invalid address",
|
||||
args: args{
|
||||
address: "test",
|
||||
},
|
||||
wantConnected: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "error dial",
|
||||
args: args{
|
||||
address: "test:1234",
|
||||
},
|
||||
wantConnected: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid tls",
|
||||
args: args{
|
||||
address: "smtp.gmail.com:465",
|
||||
},
|
||||
wantConnected: true,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if connected != tt.wantConnected {
|
||||
t.Errorf("CanPerformTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanCreateTCPConnection(t *testing.T) {
|
||||
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
|
||||
t.Error("should've failed, because there's no port in the address")
|
||||
|
||||
@@ -46,7 +46,7 @@ func (c *Config) ValidateAndSetDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
// GetHTTPClient return a HTTP client matching the Config's parameters.
|
||||
// GetHTTPClient return an HTTP client matching the Config's parameters.
|
||||
func (c *Config) getHTTPClient() *http.Client {
|
||||
if c.httpClient == nil {
|
||||
c.httpClient = &http.Client{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
services:
|
||||
endpoints:
|
||||
- name: front-end
|
||||
group: core
|
||||
url: "https://twinnation.org/health"
|
||||
interval: 1m
|
||||
url: "https://twin.sh/health"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
|
||||
185
config/config.go
@@ -7,13 +7,15 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/alerting"
|
||||
"github.com/TwinProduction/gatus/alerting/alert"
|
||||
"github.com/TwinProduction/gatus/alerting/provider"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/k8s"
|
||||
"github.com/TwinProduction/gatus/security"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwiN/gatus/v3/alerting"
|
||||
"github.com/TwiN/gatus/v3/alerting/alert"
|
||||
"github.com/TwiN/gatus/v3/alerting/provider"
|
||||
"github.com/TwiN/gatus/v3/config/maintenance"
|
||||
"github.com/TwiN/gatus/v3/config/ui"
|
||||
"github.com/TwiN/gatus/v3/config/web"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/security"
|
||||
"github.com/TwiN/gatus/v3/storage"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
@@ -25,17 +27,11 @@ const (
|
||||
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
|
||||
// configuration file if DefaultConfigurationFilePath didn't work
|
||||
DefaultFallbackConfigurationFilePath = "config/config.yml"
|
||||
|
||||
// DefaultAddress is the default address the service will bind to
|
||||
DefaultAddress = "0.0.0.0"
|
||||
|
||||
// DefaultPort is the default port the service will listen on
|
||||
DefaultPort = 8080
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoServiceInConfig is an error returned when a configuration file has no services configured
|
||||
ErrNoServiceInConfig = errors.New("configuration file should contain at least 1 service")
|
||||
// ErrNoEndpointInConfig is an error returned when a configuration file has no endpoints configured
|
||||
ErrNoEndpointInConfig = errors.New("configuration file should contain at least 1 endpoint")
|
||||
|
||||
// ErrConfigFileNotFound is an error returned when the configuration file could not be found
|
||||
ErrConfigFileNotFound = errors.New("configuration file not found")
|
||||
@@ -47,37 +43,48 @@ var (
|
||||
// Config is the main configuration structure
|
||||
type Config struct {
|
||||
// Debug Whether to enable debug logs
|
||||
Debug bool `yaml:"debug"`
|
||||
Debug bool `yaml:"debug,omitempty"`
|
||||
|
||||
// Metrics Whether to expose metrics at /metrics
|
||||
Metrics bool `yaml:"metrics"`
|
||||
Metrics bool `yaml:"metrics,omitempty"`
|
||||
|
||||
// SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration
|
||||
// if the configuration file is updated while the application is running
|
||||
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update"`
|
||||
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update,omitempty"`
|
||||
|
||||
// DisableMonitoringLock Whether to disable the monitoring lock
|
||||
// The monitoring lock is what prevents multiple services from being processed at the same time.
|
||||
// The monitoring lock is what prevents multiple endpoints from being processed at the same time.
|
||||
// Disabling this may lead to inaccurate response times
|
||||
DisableMonitoringLock bool `yaml:"disable-monitoring-lock"`
|
||||
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
|
||||
|
||||
// Security Configuration for securing access to Gatus
|
||||
Security *security.Config `yaml:"security"`
|
||||
Security *security.Config `yaml:"security,omitempty"`
|
||||
|
||||
// Alerting Configuration for alerting
|
||||
Alerting *alerting.Config `yaml:"alerting"`
|
||||
Alerting *alerting.Config `yaml:"alerting,omitempty"`
|
||||
|
||||
// Services List of services to monitor
|
||||
Services []*core.Service `yaml:"services"`
|
||||
// Endpoints List of endpoints to monitor
|
||||
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
|
||||
|
||||
// Kubernetes is the Kubernetes configuration
|
||||
Kubernetes *k8s.Config `yaml:"kubernetes"`
|
||||
// Services List of endpoints to monitor
|
||||
//
|
||||
// XXX: Remove this in v5.0.0
|
||||
// XXX: This is not a typo -- not v4.0.0, but v5.0.0 -- I want to give enough time for people to migrate
|
||||
//
|
||||
// Deprecated in favor of Endpoints
|
||||
Services []*core.Endpoint `yaml:"services,omitempty"`
|
||||
|
||||
// Storage is the configuration for how the data is stored
|
||||
Storage *storage.Config `yaml:"storage"`
|
||||
Storage *storage.Config `yaml:"storage,omitempty"`
|
||||
|
||||
// Web is the configuration for the web listener
|
||||
Web *WebConfig `yaml:"web"`
|
||||
// Web is the web configuration for the application
|
||||
Web *web.Config `yaml:"web,omitempty"`
|
||||
|
||||
// UI is the configuration for the UI
|
||||
UI *ui.Config `yaml:"ui,omitempty"`
|
||||
|
||||
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent
|
||||
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
|
||||
|
||||
filePath string // path to the file from which config was loaded from
|
||||
lastFileModTime time.Time // last modification time
|
||||
@@ -100,6 +107,8 @@ func (config *Config) UpdateLastFileModTime() {
|
||||
if !fileInfo.ModTime().IsZero() {
|
||||
config.lastFileModTime = fileInfo.ModTime()
|
||||
}
|
||||
} else {
|
||||
log.Println("[config][UpdateLastFileModTime] Ran into error updating lastFileModTime:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,33 +149,40 @@ func readConfigurationFile(fileName string) (config *Config, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
|
||||
func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
// Expand environment variables
|
||||
yamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))
|
||||
// Parse configuration file
|
||||
err = yaml.Unmarshal(yamlBytes, &config)
|
||||
if err != nil {
|
||||
if err = yaml.Unmarshal(yamlBytes, &config); err != nil {
|
||||
return
|
||||
}
|
||||
// Check if the configuration file at least has services configured or Kubernetes auto discovery enabled
|
||||
if config == nil || ((config.Services == nil || len(config.Services) == 0) && (config.Kubernetes == nil || !config.Kubernetes.AutoDiscover)) {
|
||||
err = ErrNoServiceInConfig
|
||||
if config != nil && len(config.Services) > 0 { // XXX: Remove this in v5.0.0
|
||||
log.Println("WARNING: Your configuration is using 'services:', which is deprecated in favor of 'endpoints:'.")
|
||||
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/191 for more information")
|
||||
config.Endpoints = append(config.Endpoints, config.Services...)
|
||||
config.Services = nil
|
||||
}
|
||||
// Check if the configuration file at least has endpoints configured
|
||||
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
|
||||
err = ErrNoEndpointInConfig
|
||||
} else {
|
||||
// Note that the functions below may panic, and this is on purpose to prevent Gatus from starting with
|
||||
// invalid configurations
|
||||
validateAlertingConfig(config.Alerting, config.Services, config.Debug)
|
||||
validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug)
|
||||
if err := validateSecurityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateServicesConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateKubernetesConfig(config); err != nil {
|
||||
if err := validateEndpointsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateWebConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateUIConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateMaintenanceConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateStorageConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,63 +195,55 @@ func validateStorageConfig(config *Config) error {
|
||||
config.Storage = &storage.Config{
|
||||
Type: storage.TypeMemory,
|
||||
}
|
||||
} else {
|
||||
if err := config.Storage.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := storage.Initialize(config.Storage)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMaintenanceConfig(config *Config) error {
|
||||
if config.Maintenance == nil {
|
||||
config.Maintenance = maintenance.GetDefaultConfig()
|
||||
} else {
|
||||
if err := config.Maintenance.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Remove all ServiceStatus that represent services which no longer exist in the configuration
|
||||
var keys []string
|
||||
for _, service := range config.Services {
|
||||
keys = append(keys, service.Key())
|
||||
}
|
||||
numberOfServiceStatusesDeleted := storage.Get().DeleteAllServiceStatusesNotInKeys(keys)
|
||||
if numberOfServiceStatusesDeleted > 0 {
|
||||
log.Printf("[config][validateStorageConfig] Deleted %d service statuses because their matching services no longer existed", numberOfServiceStatusesDeleted)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUIConfig(config *Config) error {
|
||||
if config.UI == nil {
|
||||
config.UI = ui.GetDefaultConfig()
|
||||
} else {
|
||||
if err := config.UI.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWebConfig(config *Config) error {
|
||||
if config.Web == nil {
|
||||
config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort}
|
||||
config.Web = web.GetDefaultConfig()
|
||||
} else {
|
||||
return config.Web.validateAndSetDefaults()
|
||||
return config.Web.ValidateAndSetDefaults()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deprecated
|
||||
// I don't like the current implementation.
|
||||
func validateKubernetesConfig(config *Config) error {
|
||||
if config.Kubernetes != nil && config.Kubernetes.AutoDiscover {
|
||||
log.Println("WARNING - The Kubernetes integration is planned to be removed in v3.0.0. If you're seeing this message, it's because you're currently using it, and you may want to give your opinion at https://github.com/TwinProduction/gatus/discussions/135")
|
||||
if config.Kubernetes.ServiceTemplate == nil {
|
||||
return errors.New("kubernetes.service-template cannot be nil")
|
||||
}
|
||||
func validateEndpointsConfig(config *Config) error {
|
||||
for _, endpoint := range config.Endpoints {
|
||||
if config.Debug {
|
||||
log.Println("[config][validateKubernetesConfig] Automatically discovering Kubernetes services...")
|
||||
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
|
||||
}
|
||||
discoveredServices, err := k8s.DiscoverServices(config.Kubernetes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.Services = append(config.Services, discoveredServices...)
|
||||
log.Printf("[config][validateKubernetesConfig] Discovered %d Kubernetes services", len(discoveredServices))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateServicesConfig(config *Config) error {
|
||||
for _, service := range config.Services {
|
||||
if config.Debug {
|
||||
log.Printf("[config][validateServicesConfig] Validating service '%s'", service.Name)
|
||||
}
|
||||
if err := service.ValidateAndSetDefaults(); err != nil {
|
||||
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Printf("[config][validateServicesConfig] Validated %d services", len(config.Services))
|
||||
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -255,10 +263,10 @@ func validateSecurityConfig(config *Config) error {
|
||||
}
|
||||
|
||||
// validateAlertingConfig validates the alerting configuration
|
||||
// Note that the alerting configuration has to be validated before the service configuration, because the default alert
|
||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Service.ValidateAndSetDefaults()
|
||||
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
|
||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Endpoint.ValidateAndSetDefaults()
|
||||
// sets the default alert values when none are set.
|
||||
func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Service, debug bool) {
|
||||
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.Endpoint, debug bool) {
|
||||
if alertingConfig == nil {
|
||||
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
|
||||
return
|
||||
@@ -266,6 +274,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Se
|
||||
alertTypes := []alert.Type{
|
||||
alert.TypeCustom,
|
||||
alert.TypeDiscord,
|
||||
alert.TypeEmail,
|
||||
alert.TypeMattermost,
|
||||
alert.TypeMessagebird,
|
||||
alert.TypePagerDuty,
|
||||
@@ -281,13 +290,13 @@ func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Se
|
||||
if alertProvider.IsValid() {
|
||||
// Parse alerts with the provider's default alert
|
||||
if alertProvider.GetDefaultAlert() != nil {
|
||||
for _, service := range services {
|
||||
for alertIndex, serviceAlert := range service.Alerts {
|
||||
if alertType == serviceAlert.Type {
|
||||
for _, endpoint := range endpoints {
|
||||
for alertIndex, endpointAlert := range endpoint.Alerts {
|
||||
if alertType == endpointAlert.Type {
|
||||
if debug {
|
||||
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in service=%s", alertIndex, alertType, service.Name)
|
||||
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name)
|
||||
}
|
||||
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), serviceAlert)
|
||||
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
145
config/maintenance/maintenance.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidMaintenanceStartFormat = errors.New("invalid maintenance start format: must be hh:mm, between 00:00 and 23:59 inclusively (e.g. 23:00)")
|
||||
errInvalidMaintenanceDuration = errors.New("invalid maintenance duration: must be bigger than 0 (e.g. 30m)")
|
||||
errInvalidDayName = fmt.Errorf("invalid value specified for 'on'. supported values are %s", longDayNames)
|
||||
|
||||
longDayNames = []string{
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
}
|
||||
)
|
||||
|
||||
// Config allows for the configuration of a maintenance period.
|
||||
// During this maintenance period, no alerts will be sent.
|
||||
//
|
||||
// Uses UTC.
|
||||
type Config struct {
|
||||
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
|
||||
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
|
||||
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
|
||||
|
||||
// Every is a list of days of the week during which maintenance period applies.
|
||||
// See longDayNames for list of valid values.
|
||||
// Every day if empty.
|
||||
Every []string `yaml:"every"`
|
||||
|
||||
durationToStartFromMidnight time.Duration
|
||||
}
|
||||
|
||||
func GetDefaultConfig() *Config {
|
||||
defaultValue := false
|
||||
return &Config{
|
||||
Enabled: &defaultValue,
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether maintenance is enabled or not
|
||||
func (c Config) IsEnabled() bool {
|
||||
if c.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *c.Enabled
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the maintenance configuration and sets the default values if necessary.
|
||||
//
|
||||
// Must be called once in the application's lifecycle before IsUnderMaintenance is called, since it
|
||||
// also sets durationToStartFromMidnight.
|
||||
func (c *Config) ValidateAndSetDefaults() error {
|
||||
if c == nil || !c.IsEnabled() {
|
||||
// Don't waste time validating if maintenance is not enabled.
|
||||
return nil
|
||||
}
|
||||
for _, day := range c.Every {
|
||||
isDayValid := false
|
||||
for _, longDayName := range longDayNames {
|
||||
if day == longDayName {
|
||||
isDayValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isDayValid {
|
||||
return errInvalidDayName
|
||||
}
|
||||
}
|
||||
var err error
|
||||
c.durationToStartFromMidnight, err = hhmmToDuration(c.Start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Duration <= 0 || c.Duration >= 24*time.Hour {
|
||||
return errInvalidMaintenanceDuration
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUnderMaintenance checks whether the endpoints that Gatus monitors are within the configured maintenance window
|
||||
func (c Config) IsUnderMaintenance() bool {
|
||||
if !c.IsEnabled() {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var dayWhereMaintenancePeriodWouldStart time.Time
|
||||
if now.Hour() >= int(c.durationToStartFromMidnight.Hours()) {
|
||||
dayWhereMaintenancePeriodWouldStart = now.Truncate(24 * time.Hour)
|
||||
} else {
|
||||
dayWhereMaintenancePeriodWouldStart = now.Add(-c.Duration).Truncate(24 * time.Hour)
|
||||
}
|
||||
hasMaintenanceEveryDay := len(c.Every) == 0
|
||||
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String())
|
||||
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
|
||||
// The day when the maintenance period would start is not scheduled
|
||||
// to have any maintenance, so we can just return false.
|
||||
return false
|
||||
}
|
||||
startOfMaintenancePeriod := dayWhereMaintenancePeriodWouldStart.Add(c.durationToStartFromMidnight)
|
||||
endOfMaintenancePeriod := startOfMaintenancePeriod.Add(c.Duration)
|
||||
return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)
|
||||
}
|
||||
|
||||
func (c Config) hasDay(day string) bool {
|
||||
for _, d := range c.Every {
|
||||
if d == day {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hhmmToDuration(s string) (time.Duration, error) {
|
||||
if len(s) != 5 {
|
||||
return 0, errInvalidMaintenanceStartFormat
|
||||
}
|
||||
var hours, minutes int
|
||||
var err error
|
||||
if hours, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[:2]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if minutes, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[3:5]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
duration := (time.Duration(hours) * time.Hour) + (time.Duration(minutes) * time.Minute)
|
||||
if hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || duration < 0 || duration >= 24*time.Hour {
|
||||
return 0, errInvalidMaintenanceStartFormat
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func extractNumericalValueFromPotentiallyZeroPaddedString(s string) (int, error) {
|
||||
return strconv.Atoi(strings.TrimPrefix(s, "0"))
|
||||
}
|
||||
226
config/maintenance/maintenance_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetDefaultConfig(t *testing.T) {
|
||||
if *GetDefaultConfig().Enabled {
|
||||
t.Fatal("expected default config to be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
yes, no := true, false
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "nil",
|
||||
cfg: nil,
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "disabled",
|
||||
cfg: &Config{
|
||||
Enabled: &no,
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-day",
|
||||
cfg: &Config{
|
||||
Every: []string{"invalid-day"},
|
||||
},
|
||||
expectedError: errInvalidDayName,
|
||||
},
|
||||
{
|
||||
name: "invalid-day",
|
||||
cfg: &Config{
|
||||
Every: []string{"invalid-day"},
|
||||
},
|
||||
expectedError: errInvalidDayName,
|
||||
},
|
||||
{
|
||||
name: "invalid-start-format",
|
||||
cfg: &Config{
|
||||
Start: "0000",
|
||||
},
|
||||
expectedError: errInvalidMaintenanceStartFormat,
|
||||
},
|
||||
{
|
||||
name: "invalid-start-hours",
|
||||
cfg: &Config{
|
||||
Start: "25:00",
|
||||
},
|
||||
expectedError: errInvalidMaintenanceStartFormat,
|
||||
},
|
||||
{
|
||||
name: "invalid-start-minutes",
|
||||
cfg: &Config{
|
||||
Start: "0:61",
|
||||
},
|
||||
expectedError: errInvalidMaintenanceStartFormat,
|
||||
},
|
||||
{
|
||||
name: "invalid-start-minutes-non-numerical",
|
||||
cfg: &Config{
|
||||
Start: "00:zz",
|
||||
},
|
||||
expectedError: strconv.ErrSyntax,
|
||||
},
|
||||
{
|
||||
name: "invalid-start-hours-non-numerical",
|
||||
cfg: &Config{
|
||||
Start: "zz:00",
|
||||
},
|
||||
expectedError: strconv.ErrSyntax,
|
||||
},
|
||||
{
|
||||
name: "invalid-duration",
|
||||
cfg: &Config{
|
||||
Start: "23:00",
|
||||
Duration: 0,
|
||||
},
|
||||
expectedError: errInvalidMaintenanceDuration,
|
||||
},
|
||||
{
|
||||
name: "every-day-at-2300",
|
||||
cfg: &Config{
|
||||
Start: "23:00",
|
||||
Duration: time.Hour,
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "every-monday-at-0000",
|
||||
cfg: &Config{
|
||||
Start: "00:00",
|
||||
Duration: 30 * time.Minute,
|
||||
Every: []string{"Monday"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "every-friday-and-sunday-at-0000-explicitly-enabled",
|
||||
cfg: &Config{
|
||||
Enabled: &yes,
|
||||
Start: "08:00",
|
||||
Duration: 8 * time.Hour,
|
||||
Every: []string{"Friday", "Sunday"},
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.cfg.ValidateAndSetDefaults()
|
||||
if !errors.Is(err, scenario.expectedError) {
|
||||
t.Errorf("expected %v, got %v", scenario.expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
yes, no := true, false
|
||||
now := time.Now().UTC()
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "disabled",
|
||||
cfg: &Config{
|
||||
Enabled: &no,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-explicitly-enabled",
|
||||
cfg: &Config{
|
||||
Enabled: &yes,
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-2h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-8h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 8 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-4h-ago-for-8h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||
Duration: 8 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-4h-ago-for-3h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||
Duration: 3 * time.Hour,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-5h-ago-for-1h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)),
|
||||
Duration: time.Hour,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-today",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: time.Hour,
|
||||
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
t.Log(scenario.cfg.Start)
|
||||
t.Log(now)
|
||||
if err := scenario.cfg.ValidateAndSetDefaults(); err != nil {
|
||||
t.Fatal("validation shouldn't have returned an error, got", err)
|
||||
}
|
||||
isUnderMaintenance := scenario.cfg.IsUnderMaintenance()
|
||||
if isUnderMaintenance != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, isUnderMaintenance)
|
||||
t.Logf("start=%v; duration=%v; now=%v", scenario.cfg.Start, scenario.cfg.Duration, time.Now().UTC())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHour(hour int) int {
|
||||
if hour < 0 {
|
||||
return hour + 24
|
||||
}
|
||||
return hour
|
||||
}
|
||||
48
config/ui/ui.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTitle = "Health Dashboard | Gatus"
|
||||
defaultLogo = ""
|
||||
)
|
||||
|
||||
var (
|
||||
// StaticFolder is the path to the location of the static folder from the root path of the project
|
||||
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
|
||||
StaticFolder = "./web/static"
|
||||
)
|
||||
|
||||
// Config is the configuration for the UI of Gatus
|
||||
type Config struct {
|
||||
Title string `yaml:"title"` // Title of the page
|
||||
Logo string `yaml:"logo"` // Logo to display on the page
|
||||
}
|
||||
|
||||
// GetDefaultConfig returns a Config struct with the default values
|
||||
func GetDefaultConfig() *Config {
|
||||
return &Config{
|
||||
Title: defaultTitle,
|
||||
Logo: defaultLogo,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the UI configuration and sets the default values if necessary.
|
||||
func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
if len(cfg.Title) == 0 {
|
||||
cfg.Title = defaultTitle
|
||||
}
|
||||
t, err := template.ParseFiles(StaticFolder + "/index.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
err = t.Execute(&buffer, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
26
config/ui/ui_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
StaticFolder = "../../web/static"
|
||||
defer func() {
|
||||
StaticFolder = "./web/static"
|
||||
}()
|
||||
cfg := &Config{Title: ""}
|
||||
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultConfig(t *testing.T) {
|
||||
defaultConfig := GetDefaultConfig()
|
||||
if defaultConfig.Title != defaultTitle {
|
||||
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
|
||||
}
|
||||
if defaultConfig.Logo != defaultLogo {
|
||||
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
package config
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// WebConfig is the structure which supports the configuration of the endpoint
|
||||
const (
|
||||
// DefaultAddress is the default address the application will bind to
|
||||
DefaultAddress = "0.0.0.0"
|
||||
|
||||
// DefaultPort is the default port the application will listen on
|
||||
DefaultPort = 8080
|
||||
)
|
||||
|
||||
// Config is the structure which supports the configuration of the endpoint
|
||||
// which provides access to the web frontend
|
||||
type WebConfig struct {
|
||||
type Config struct {
|
||||
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
|
||||
Address string `yaml:"address"`
|
||||
|
||||
@@ -15,8 +23,13 @@ type WebConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
// validateAndSetDefaults checks and sets the default values for fields that are not set
|
||||
func (web *WebConfig) validateAndSetDefaults() error {
|
||||
// GetDefaultConfig returns a Config struct with the default values
|
||||
func GetDefaultConfig() *Config {
|
||||
return &Config{Address: DefaultAddress, Port: DefaultPort}
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the web configuration and sets the default values if necessary.
|
||||
func (web *Config) ValidateAndSetDefaults() error {
|
||||
// Validate the Address
|
||||
if len(web.Address) == 0 {
|
||||
web.Address = DefaultAddress
|
||||
@@ -31,6 +44,6 @@ func (web *WebConfig) validateAndSetDefaults() error {
|
||||
}
|
||||
|
||||
// SocketAddress returns the combination of the Address and the Port
|
||||
func (web *WebConfig) SocketAddress() string {
|
||||
func (web *Config) SocketAddress() string {
|
||||
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
||||
}
|
||||
65
config/web/web_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetDefaultConfig(t *testing.T) {
|
||||
defaultConfig := GetDefaultConfig()
|
||||
if defaultConfig.Port != DefaultPort {
|
||||
t.Error("expected default config to have the default port")
|
||||
}
|
||||
if defaultConfig.Address != DefaultAddress {
|
||||
t.Error("expected default config to have the default address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectedAddress string
|
||||
expectedPort int
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "no-explicit-config",
|
||||
cfg: &Config{},
|
||||
expectedAddress: "0.0.0.0",
|
||||
expectedPort: 8080,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid-port",
|
||||
cfg: &Config{Port: 100000000},
|
||||
expectedErr: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.cfg.ValidateAndSetDefaults()
|
||||
if (err != nil) != scenario.expectedErr {
|
||||
t.Errorf("expected the existence of an error to be %v, got %v", scenario.expectedErr, err)
|
||||
return
|
||||
}
|
||||
if !scenario.expectedErr {
|
||||
if scenario.cfg.Port != scenario.expectedPort {
|
||||
t.Errorf("expected port to be %d, got %d", scenario.expectedPort, scenario.cfg.Port)
|
||||
}
|
||||
if scenario.cfg.Address != scenario.expectedAddress {
|
||||
t.Errorf("expected address to be %s, got %s", scenario.expectedAddress, scenario.cfg.Address)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_SocketAddress(t *testing.T) {
|
||||
web := &Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8081,
|
||||
}
|
||||
if web.SocketAddress() != "0.0.0.0:8081" {
|
||||
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebConfig_SocketAddress(t *testing.T) {
|
||||
web := &WebConfig{
|
||||
Address: "0.0.0.0",
|
||||
Port: 8081,
|
||||
}
|
||||
if web.SocketAddress() != "0.0.0.0:8081" {
|
||||
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// badgeHandler handles the automatic generation of badge based on the group name and service name passed.
|
||||
//
|
||||
// Valid values for {duration}: 7d, 24h, 1h
|
||||
// Pattern for {identifier}: <KEY>.svg
|
||||
func badgeHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
variables := mux.Vars(request)
|
||||
duration := variables["duration"]
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
from = time.Now().Add(-time.Hour * 24 * 7)
|
||||
case "24h":
|
||||
from = time.Now().Add(-time.Hour * 24)
|
||||
case "1h":
|
||||
from = time.Now().Add(-time.Hour)
|
||||
default:
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
|
||||
return
|
||||
}
|
||||
identifier := variables["identifier"]
|
||||
key := strings.TrimSuffix(identifier, ".svg")
|
||||
uptime, err := storage.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrServiceNotFound {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
} else {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
_, _ = writer.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
formattedDate := time.Now().Format(http.TimeFormat)
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Date", formattedDate)
|
||||
writer.Header().Set("Expires", formattedDate)
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
_, _ = writer.Write(generateSVG(duration, uptime))
|
||||
}
|
||||
|
||||
func generateSVG(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 "#40cc11"
|
||||
} else if uptime >= 0.95 {
|
||||
return "#94cc11"
|
||||
} else if uptime >= 0.9 {
|
||||
return "#ccc311"
|
||||
} else if uptime >= 0.8 {
|
||||
return "#ccb311"
|
||||
} else if uptime >= 0.5 {
|
||||
return "#cc8111"
|
||||
}
|
||||
return "#c7130a"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetBadgeColorFromUptime(t *testing.T) {
|
||||
if getBadgeColorFromUptime(1) != "#40cc11" {
|
||||
t.Error("expected #40cc11 from an uptime of 1, got", getBadgeColorFromUptime(1))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.95) != "#94cc11" {
|
||||
t.Error("expected #94cc11 from an uptime of 0.95, got", getBadgeColorFromUptime(0.95))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.9) != "#ccc311" {
|
||||
t.Error("expected #c9cc11 from an uptime of 0.9, got", getBadgeColorFromUptime(0.9))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.85) != "#ccb311" {
|
||||
t.Error("expected #ccb311 from an uptime of 0.85, got", getBadgeColorFromUptime(0.85))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.75) != "#cc8111" {
|
||||
t.Error("expected #cc8111 from an uptime of 0.75, got", getBadgeColorFromUptime(0.75))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.6) != "#cc8111" {
|
||||
t.Error("expected #cc8111 from an uptime of 0.6, got", getBadgeColorFromUptime(0.6))
|
||||
}
|
||||
if getBadgeColorFromUptime(0.25) != "#c7130a" {
|
||||
t.Error("expected #c7130a from an uptime of 0.25, got", getBadgeColorFromUptime(0.25))
|
||||
}
|
||||
if getBadgeColorFromUptime(0) != "#c7130a" {
|
||||
t.Error("expected #c7130a from an uptime of 0, got", getBadgeColorFromUptime(0))
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,30 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/security"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwinProduction/gatus/storage/store/common/paging"
|
||||
"github.com/TwinProduction/gocache"
|
||||
"github.com/TwinProduction/health"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTL = 10 * time.Second
|
||||
"github.com/TwiN/gatus/v3/config/ui"
|
||||
"github.com/TwiN/gatus/v3/config/web"
|
||||
"github.com/TwiN/gatus/v3/controller/handler"
|
||||
"github.com/TwiN/gatus/v3/security"
|
||||
)
|
||||
|
||||
var (
|
||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
||||
|
||||
// staticFolder is the path to the location of the static folder from the root path of the project
|
||||
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
|
||||
staticFolder = "./web/static"
|
||||
|
||||
// server is the http.Server created by Handle.
|
||||
// The only reason it exists is for testing purposes.
|
||||
server *http.Server
|
||||
)
|
||||
|
||||
// Handle creates the router and starts the server
|
||||
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, enableMetrics bool) {
|
||||
var router http.Handler = CreateRouter(securityConfig, enableMetrics)
|
||||
func Handle(securityConfig *security.Config, webConfig *web.Config, uiConfig *ui.Config, enableMetrics bool) {
|
||||
var router http.Handler = handler.CreateRouter(ui.StaticFolder, securityConfig, uiConfig, enableMetrics)
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
router = developmentCorsHandler(router)
|
||||
router = handler.DevelopmentCORS(router)
|
||||
}
|
||||
server = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
|
||||
@@ -66,109 +47,3 @@ func Shutdown() {
|
||||
server = nil
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRouter creates the router for the http server
|
||||
func CreateRouter(securityConfig *security.Config, enabledMetrics bool) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
if enabledMetrics {
|
||||
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
|
||||
}
|
||||
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
||||
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET")
|
||||
router.HandleFunc("/api/v1/statuses", secureIfNecessary(securityConfig, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content
|
||||
router.HandleFunc("/api/v1/statuses/{key}", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).Methods("GET")
|
||||
router.HandleFunc("/api/v1/badges/uptime/{duration}/{identifier}", badgeHandler).Methods("GET")
|
||||
// SPA
|
||||
router.HandleFunc("/services/{service}", spaHandler).Methods("GET")
|
||||
// Everything else falls back on static content
|
||||
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
|
||||
return router
|
||||
}
|
||||
|
||||
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
|
||||
if securityConfig != nil && securityConfig.IsValid() {
|
||||
return security.Handler(handler, securityConfig)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
// serviceStatusesHandler handles requests to retrieve all service statuses
|
||||
// Due to the size of the response, this function leverages a cache.
|
||||
// Must not be wrapped by GzipHandler
|
||||
func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
|
||||
var exists bool
|
||||
var value interface{}
|
||||
if gzipped {
|
||||
writer.Header().Set("Content-Encoding", "gzip")
|
||||
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize))
|
||||
} else {
|
||||
value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d", page, pageSize))
|
||||
}
|
||||
var data []byte
|
||||
if !exists {
|
||||
var err error
|
||||
buffer := &bytes.Buffer{}
|
||||
gzipWriter := gzip.NewWriter(buffer)
|
||||
data, err = json.Marshal(storage.Get().GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(page, pageSize)))
|
||||
if err != nil {
|
||||
log.Printf("[controller][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = writer.Write([]byte("Unable to marshal object to JSON"))
|
||||
return
|
||||
}
|
||||
_, _ = gzipWriter.Write(data)
|
||||
_ = gzipWriter.Close()
|
||||
gzippedData := buffer.Bytes()
|
||||
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d", page, pageSize), data, cacheTTL)
|
||||
cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
|
||||
if gzipped {
|
||||
data = gzippedData
|
||||
}
|
||||
} else {
|
||||
data = value.([]byte)
|
||||
}
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(data)
|
||||
}
|
||||
|
||||
// serviceStatusHandler retrieves a single ServiceStatus by group name and service name
|
||||
func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
vars := mux.Vars(r)
|
||||
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
||||
if serviceStatus == nil {
|
||||
log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"])
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = writer.Write([]byte("not found"))
|
||||
return
|
||||
}
|
||||
uptime7Days, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour*24*7), time.Now())
|
||||
uptime24Hours, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour*24), time.Now())
|
||||
uptime1Hour, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour), time.Now())
|
||||
data := map[string]interface{}{
|
||||
"serviceStatus": serviceStatus,
|
||||
// The following fields, while present on core.ServiceStatus, are annotated to remain hidden so that we can
|
||||
// expose only the necessary data on /api/v1/statuses.
|
||||
// Since the /api/v1/statuses/{key} endpoint does need this data, however, we explicitly expose it here
|
||||
"events": serviceStatus.Events,
|
||||
// TODO: remove this in v3.0.0. Not used by front-end, only used for API. Left here for v2.x.x backward compatibility
|
||||
"uptime": map[string]float64{
|
||||
"7d": uptime7Days,
|
||||
"24h": uptime24Hours,
|
||||
"1h": uptime1Hour,
|
||||
},
|
||||
}
|
||||
output, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Printf("[controller][serviceStatusHandler] Unable to marshal object to JSON: %s", err.Error())
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = writer.Write([]byte("unable to marshal object to JSON"))
|
||||
return
|
||||
}
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(output)
|
||||
}
|
||||
|
||||
@@ -6,224 +6,19 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/config"
|
||||
"github.com/TwinProduction/gatus/core"
|
||||
"github.com/TwinProduction/gatus/storage"
|
||||
"github.com/TwinProduction/gatus/watchdog"
|
||||
"github.com/TwiN/gatus/v3/config"
|
||||
"github.com/TwiN/gatus/v3/config/web"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
)
|
||||
|
||||
var (
|
||||
firstCondition = core.Condition("[STATUS] == 200")
|
||||
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
|
||||
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||
|
||||
timestamp = time.Now()
|
||||
|
||||
testService = core.Service{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
URL: "https://example.org/what/ever",
|
||||
Method: "GET",
|
||||
Body: "body",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
|
||||
Alerts: nil,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
testSuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: timestamp,
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
testUnsuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: []string{"error-1", "error-2"},
|
||||
Connected: true,
|
||||
Success: false,
|
||||
Timestamp: timestamp,
|
||||
Duration: 750 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: false,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestCreateRouter(t *testing.T) {
|
||||
defer storage.Get().Clear()
|
||||
defer cache.Clear()
|
||||
staticFolder = "../web/static"
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Services: []*core.Service{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter(cfg.Security, cfg.Metrics)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "health",
|
||||
Path: "/health",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Path: "/metrics",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-1h",
|
||||
Path: "/api/v1/badges/uptime/1h/core_frontend.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-24h",
|
||||
Path: "/api/v1/badges/uptime/24h/core_backend.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-7d",
|
||||
Path: "/api/v1/badges/uptime/7d/core_frontend.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badges-with-invalid-duration",
|
||||
Path: "/api/v1/badges/uptime/3d/core_backend.svg",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "badges-for-invalid-key",
|
||||
Path: "/api/v1/badges/uptime/7d/invalid_key.svg",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "service-statuses",
|
||||
Path: "/api/v1/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "service-statuses-gzip",
|
||||
Path: "/api/v1/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "service-statuses-pagination",
|
||||
Path: "/api/v1/statuses?page=1&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "service-status",
|
||||
Path: "/api/v1/statuses/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "service-status-gzip",
|
||||
Path: "/api/v1/statuses/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "service-status-for-invalid-key",
|
||||
Path: "/api/v1/statuses/invalid_key",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "favicon",
|
||||
Path: "/favicon.ico",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-assets",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-service",
|
||||
Path: "/services/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, nil)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle(t *testing.T) {
|
||||
defer storage.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Web: &config.WebConfig{
|
||||
Web: &web.Config{
|
||||
Address: "0.0.0.0",
|
||||
Port: rand.Intn(65534),
|
||||
},
|
||||
Services: []*core.Service{
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
@@ -237,9 +32,9 @@ func TestHandle(t *testing.T) {
|
||||
_ = os.Setenv("ROUTER_TEST", "true")
|
||||
_ = os.Setenv("ENVIRONMENT", "dev")
|
||||
defer os.Clearenv()
|
||||
Handle(cfg.Security, cfg.Web, cfg.Metrics)
|
||||
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
|
||||
defer Shutdown()
|
||||
request, _ := http.NewRequest("GET", "/health", nil)
|
||||
request, _ := http.NewRequest("GET", "/health", http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
@@ -258,71 +53,3 @@ func TestShutdown(t *testing.T) {
|
||||
t.Error("server should've been shut down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusesHandler(t *testing.T) {
|
||||
defer storage.Get().Clear()
|
||||
defer cache.Clear()
|
||||
staticFolder = "../web/static"
|
||||
firstResult := &testSuccessfulResult
|
||||
secondResult := &testUnsuccessfulResult
|
||||
storage.Get().Insert(&testService, firstResult)
|
||||
storage.Get().Insert(&testService, secondResult)
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
router := CreateRouter(nil, false)
|
||||
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
ExpectedBody string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "no-pagination",
|
||||
Path: "/api/v1/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-first-result",
|
||||
Path: "/api/v1/statuses?page=1&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-second-result",
|
||||
Path: "/api/v1/statuses?page=2&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-no-results",
|
||||
Path: "/api/v1/statuses?page=5&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[]}}`,
|
||||
},
|
||||
{
|
||||
Name: "invalid-pagination-should-fall-back-to-default",
|
||||
Path: "/api/v1/statuses?page=INVALID&pageSize=INVALID",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `{"group_name":{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, nil)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
output := responseRecorder.Body.String()
|
||||
if output != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package controller
|
||||
|
||||
import "net/http"
|
||||
|
||||
// favIconHandler handles requests for /favicon.ico
|
||||
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
|
||||
}
|
||||
225
controller/handler/badge.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v3/storage/store"
|
||||
"github.com/TwiN/gatus/v3/storage/store/common"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
badgeColorHexAwesome = "#40cc11"
|
||||
badgeColorHexGreat = "#94cc11"
|
||||
badgeColorHexGood = "#ccd311"
|
||||
badgeColorHexPassable = "#ccb311"
|
||||
badgeColorHexBad = "#cc8111"
|
||||
badgeColorHexVeryBad = "#c7130a"
|
||||
)
|
||||
|
||||
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
//
|
||||
// Valid values for {duration}: 7d, 24h, 1h
|
||||
func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
|
||||
variables := mux.Vars(request)
|
||||
duration := variables["duration"]
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Add(-24 * time.Hour)
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := variables["key"]
|
||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime))
|
||||
}
|
||||
|
||||
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
//
|
||||
// Valid values for {duration}: 7d, 24h, 1h
|
||||
func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
|
||||
variables := mux.Vars(request)
|
||||
duration := variables["duration"]
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Add(-24 * time.Hour)
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := variables["key"]
|
||||
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime))
|
||||
}
|
||||
|
||||
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) []byte {
|
||||
var labelWidth, valueWidth int
|
||||
switch duration {
|
||||
case "7d":
|
||||
labelWidth = 105
|
||||
case "24h":
|
||||
labelWidth = 110
|
||||
case "1h":
|
||||
labelWidth = 105
|
||||
default:
|
||||
}
|
||||
color := getBadgeColorFromResponseTime(averageResponseTime)
|
||||
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
|
||||
valueWidth = len(sanitizedValue) * 11
|
||||
width := labelWidth + valueWidth
|
||||
labelX := labelWidth / 2
|
||||
valueX := labelWidth + (valueWidth / 2)
|
||||
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
|
||||
<linearGradient id="b" x2="0" y2="100%%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<mask id="a">
|
||||
<rect width="%d" height="20" rx="3" fill="#fff"/>
|
||||
</mask>
|
||||
<g mask="url(#a)">
|
||||
<path fill="#555" d="M0 0h%dv20H0z"/>
|
||||
<path fill="%s" d="M%d 0h%dv20H%dz"/>
|
||||
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
||||
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||
response time %s
|
||||
</text>
|
||||
<text x="%d" y="14">
|
||||
response time %s
|
||||
</text>
|
||||
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
|
||||
%s
|
||||
</text>
|
||||
<text x="%d" y="14">
|
||||
%s
|
||||
</text>
|
||||
</g>
|
||||
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
|
||||
return svg
|
||||
}
|
||||
|
||||
func getBadgeColorFromResponseTime(responseTime int) string {
|
||||
if responseTime <= 50 {
|
||||
return badgeColorHexAwesome
|
||||
} else if responseTime <= 200 {
|
||||
return badgeColorHexGreat
|
||||
} else if responseTime <= 300 {
|
||||
return badgeColorHexGood
|
||||
} else if responseTime <= 500 {
|
||||
return badgeColorHexPassable
|
||||
} else if responseTime <= 750 {
|
||||
return badgeColorHexBad
|
||||
}
|
||||
return badgeColorHexVeryBad
|
||||
}
|
||||
231
controller/handler/badge_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v3/config"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/storage/store"
|
||||
"github.com/TwiN/gatus/v3/watchdog"
|
||||
)
|
||||
|
||||
func TestUptimeBadge(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
|
||||
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: "chart-response-time-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{ // XXX: Remove this in v4.0.0
|
||||
Name: "backward-compatible-services-badge-uptime-1h",
|
||||
Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{ // 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 {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBadgeColorFromUptime(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Uptime float64
|
||||
ExpectedColor string
|
||||
}{
|
||||
{
|
||||
Uptime: 1,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
Uptime: 0.99,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
Uptime: 0.97,
|
||||
ExpectedColor: badgeColorHexGreat,
|
||||
},
|
||||
{
|
||||
Uptime: 0.95,
|
||||
ExpectedColor: badgeColorHexGreat,
|
||||
},
|
||||
{
|
||||
Uptime: 0.93,
|
||||
ExpectedColor: badgeColorHexGood,
|
||||
},
|
||||
{
|
||||
Uptime: 0.9,
|
||||
ExpectedColor: badgeColorHexGood,
|
||||
},
|
||||
{
|
||||
Uptime: 0.85,
|
||||
ExpectedColor: badgeColorHexPassable,
|
||||
},
|
||||
{
|
||||
Uptime: 0.7,
|
||||
ExpectedColor: badgeColorHexBad,
|
||||
},
|
||||
{
|
||||
Uptime: 0.65,
|
||||
ExpectedColor: badgeColorHexBad,
|
||||
},
|
||||
{
|
||||
Uptime: 0.6,
|
||||
ExpectedColor: badgeColorHexVeryBad,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run("uptime-"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) {
|
||||
if getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor {
|
||||
t.Errorf("expected %s from %f, got %v", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
ResponseTime int
|
||||
ExpectedColor string
|
||||
}{
|
||||
{
|
||||
ResponseTime: 10,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
ResponseTime: 50,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
ResponseTime: 75,
|
||||
ExpectedColor: badgeColorHexGreat,
|
||||
},
|
||||
{
|
||||
ResponseTime: 150,
|
||||
ExpectedColor: badgeColorHexGreat,
|
||||
},
|
||||
{
|
||||
ResponseTime: 201,
|
||||
ExpectedColor: badgeColorHexGood,
|
||||
},
|
||||
{
|
||||
ResponseTime: 300,
|
||||
ExpectedColor: badgeColorHexGood,
|
||||
},
|
||||
{
|
||||
ResponseTime: 301,
|
||||
ExpectedColor: badgeColorHexPassable,
|
||||
},
|
||||
{
|
||||
ResponseTime: 450,
|
||||
ExpectedColor: badgeColorHexPassable,
|
||||
},
|
||||
{
|
||||
ResponseTime: 700,
|
||||
ExpectedColor: badgeColorHexBad,
|
||||
},
|
||||
{
|
||||
ResponseTime: 1500,
|
||||
ExpectedColor: badgeColorHexVeryBad,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
|
||||
if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor {
|
||||
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
122
controller/handler/chart.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v3/storage/store"
|
||||
"github.com/TwiN/gatus/v3/storage/store/common"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
const timeFormat = "3:04PM"
|
||||
|
||||
var (
|
||||
gridStyle = chart.Style{
|
||||
StrokeColor: drawing.Color{R: 119, G: 119, B: 119, A: 40},
|
||||
StrokeWidth: 1.0,
|
||||
}
|
||||
axisStyle = chart.Style{
|
||||
FontColor: drawing.Color{R: 119, G: 119, B: 119, A: 255},
|
||||
}
|
||||
transparentStyle = chart.Style{
|
||||
FillColor: drawing.Color{R: 255, G: 255, B: 255, A: 0},
|
||||
}
|
||||
)
|
||||
|
||||
func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
duration := vars["duration"]
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
from = time.Now().Truncate(time.Hour).Add(-24 * 7 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||
default:
|
||||
http.Error(writer, "Durations supported: 7d, 24h", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(hourlyAverageResponseTime) == 0 {
|
||||
http.Error(writer, "", http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
series := chart.TimeSeries{
|
||||
Name: "Average response time per hour",
|
||||
Style: chart.Style{
|
||||
StrokeWidth: 1.5,
|
||||
DotWidth: 2.0,
|
||||
},
|
||||
}
|
||||
keys := make([]int, 0, len(hourlyAverageResponseTime))
|
||||
earliestTimestamp := int64(0)
|
||||
for hourlyTimestamp := range hourlyAverageResponseTime {
|
||||
keys = append(keys, int(hourlyTimestamp))
|
||||
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
|
||||
earliestTimestamp = hourlyTimestamp
|
||||
}
|
||||
}
|
||||
for earliestTimestamp > from.Unix() {
|
||||
earliestTimestamp -= int64(time.Hour.Seconds())
|
||||
keys = append(keys, int(earliestTimestamp))
|
||||
}
|
||||
sort.Ints(keys)
|
||||
var maxAverageResponseTime float64
|
||||
for _, key := range keys {
|
||||
averageResponseTime := float64(hourlyAverageResponseTime[int64(key)])
|
||||
if maxAverageResponseTime < averageResponseTime {
|
||||
maxAverageResponseTime = averageResponseTime
|
||||
}
|
||||
series.XValues = append(series.XValues, time.Unix(int64(key), 0))
|
||||
series.YValues = append(series.YValues, averageResponseTime)
|
||||
}
|
||||
graph := chart.Chart{
|
||||
Canvas: transparentStyle,
|
||||
Background: transparentStyle,
|
||||
Width: 1280,
|
||||
Height: 300,
|
||||
XAxis: chart.XAxis{
|
||||
ValueFormatter: chart.TimeValueFormatterWithFormat(timeFormat),
|
||||
GridMajorStyle: gridStyle,
|
||||
GridMinorStyle: gridStyle,
|
||||
Style: axisStyle,
|
||||
NameStyle: axisStyle,
|
||||
},
|
||||
YAxis: chart.YAxis{
|
||||
Name: "Average response time",
|
||||
GridMajorStyle: gridStyle,
|
||||
GridMinorStyle: gridStyle,
|
||||
Style: axisStyle,
|
||||
NameStyle: axisStyle,
|
||||
Range: &chart.ContinuousRange{
|
||||
Min: 0,
|
||||
Max: math.Ceil(maxAverageResponseTime * 1.25),
|
||||
},
|
||||
},
|
||||
Series: []chart.Series{series},
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
if err := graph.Render(chart.SVG, writer); err != nil {
|
||||
log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
80
controller/handler/chart_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v3/config"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/storage/store"
|
||||
"github.com/TwiN/gatus/v3/watchdog"
|
||||
)
|
||||
|
||||
func TestResponseTimeChart(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
|
||||
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 {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package controller
|
||||
package handler
|
||||
|
||||
import "net/http"
|
||||
|
||||
func developmentCorsHandler(next http.Handler) http.Handler {
|
||||
func DevelopmentCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
|
||||
next.ServeHTTP(w, r)
|
||||
103
controller/handler/endpoint_status.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v3/storage/store"
|
||||
"github.com/TwiN/gatus/v3/storage/store/common"
|
||||
"github.com/TwiN/gatus/v3/storage/store/common/paging"
|
||||
"github.com/TwiN/gocache"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
||||
)
|
||||
|
||||
// EndpointStatuses handles requests to retrieve all EndpointStatus
|
||||
// Due to the size of the response, this function leverages a cache.
|
||||
// Must not be wrapped by GzipHandler
|
||||
func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
|
||||
var exists bool
|
||||
var value interface{}
|
||||
if gzipped {
|
||||
writer.Header().Set("Content-Encoding", "gzip")
|
||||
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize))
|
||||
} else {
|
||||
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
|
||||
}
|
||||
var data []byte
|
||||
if !exists {
|
||||
var err error
|
||||
buffer := &bytes.Buffer{}
|
||||
gzipWriter := gzip.NewWriter(buffer)
|
||||
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
|
||||
if err != nil {
|
||||
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data, err = json.Marshal(endpointStatuses)
|
||||
if err != nil {
|
||||
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
|
||||
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = gzipWriter.Write(data)
|
||||
_ = gzipWriter.Close()
|
||||
gzippedData := buffer.Bytes()
|
||||
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
|
||||
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
|
||||
if gzipped {
|
||||
data = gzippedData
|
||||
}
|
||||
} else {
|
||||
data = value.([]byte)
|
||||
}
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(data)
|
||||
}
|
||||
|
||||
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
|
||||
func EndpointStatus(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
vars := mux.Vars(r)
|
||||
endpointStatus, err := store.Get().GetEndpointStatusByKey(vars["key"], paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("[handler][EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if endpointStatus == nil {
|
||||
log.Printf("[handler][EndpointStatus] Endpoint with key=%s not found", vars["key"])
|
||||
http.Error(writer, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
output, err := json.Marshal(endpointStatus)
|
||||
if err != nil {
|
||||
log.Printf("[handler][EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
||||
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(output)
|
||||
}
|
||||
226
controller/handler/endpoint_status_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v3/config"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/storage/store"
|
||||
"github.com/TwiN/gatus/v3/watchdog"
|
||||
)
|
||||
|
||||
var (
|
||||
firstCondition = core.Condition("[STATUS] == 200")
|
||||
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
|
||||
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||
|
||||
timestamp = time.Now()
|
||||
|
||||
testEndpoint = core.Endpoint{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
URL: "https://example.org/what/ever",
|
||||
Method: "GET",
|
||||
Body: "body",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition},
|
||||
Alerts: nil,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
testSuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: timestamp,
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
testUnsuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: []string{"error-1", "error-2"},
|
||||
Connected: true,
|
||||
Success: false,
|
||||
Timestamp: timestamp,
|
||||
Duration: 750 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: false,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestEndpointStatus(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
|
||||
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "endpoint-status",
|
||||
Path: "/api/v1/endpoints/core_frontend/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "endpoint-status-gzip",
|
||||
Path: "/api/v1/endpoints/core_frontend/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "endpoint-status-pagination",
|
||||
Path: "/api/v1/endpoints/core_frontend/statuses?page=1&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "endpoint-status-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/statuses",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{ // XXX: Remove this in v4.0.0
|
||||
Name: "backward-compatible-service-status",
|
||||
Path: "/api/v1/services/core_frontend/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointStatuses(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
firstResult := &testSuccessfulResult
|
||||
secondResult := &testUnsuccessfulResult
|
||||
store.Get().Insert(&testEndpoint, firstResult)
|
||||
store.Get().Insert(&testEndpoint, secondResult)
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
router := CreateRouter("../../web/static", nil, nil, false)
|
||||
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
ExpectedBody string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "no-pagination",
|
||||
Path: "/api/v1/endpoints/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-first-result",
|
||||
Path: "/api/v1/endpoints/statuses?page=1&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-second-result",
|
||||
Path: "/api/v1/endpoints/statuses?page=2&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-no-results",
|
||||
Path: "/api/v1/endpoints/statuses?page=5&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[]}]`,
|
||||
},
|
||||
{
|
||||
Name: "invalid-pagination-should-fall-back-to-default",
|
||||
Path: "/api/v1/endpoints/statuses?page=INVALID&pageSize=INVALID",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
|
||||
},
|
||||
{ // XXX: Remove this in v4.0.0
|
||||
Name: "backward-compatible-service-status",
|
||||
Path: "/api/v1/services/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
output := responseRecorder.Body.String()
|
||||
if output != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
12
controller/handler/favicon.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// FavIcon handles requests for /favicon.ico
|
||||
func FavIcon(staticFolder string) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
|
||||
}
|
||||
}
|
||||
33
controller/handler/favicon_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFavIcon(t *testing.T) {
|
||||
router := CreateRouter("../../web/static", nil, nil, false)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "favicon",
|
||||
Path: "/favicon.ico",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package controller
|
||||
package handler
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
47
controller/handler/handler.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/config/ui"
|
||||
"github.com/TwiN/gatus/v3/security"
|
||||
"github.com/TwiN/health"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *ui.Config, enabledMetrics bool) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
if enabledMetrics {
|
||||
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
|
||||
}
|
||||
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
||||
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
|
||||
// Endpoints
|
||||
router.HandleFunc("/api/v1/endpoints/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
|
||||
router.HandleFunc("/api/v1/endpoints/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET")
|
||||
router.HandleFunc("/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
|
||||
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
|
||||
router.HandleFunc("/api/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||
// XXX: Remove the lines between this and the next XXX comment in v4.0.0
|
||||
router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, EndpointStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
|
||||
router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(EndpointStatus))).Methods("GET")
|
||||
router.HandleFunc("/api/v1/services/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
|
||||
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
|
||||
router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||
// XXX: Remove the lines between this and the previous XXX comment in v4.0.0
|
||||
// SPA
|
||||
router.HandleFunc("/services/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") // XXX: Remove this in v4.0.0
|
||||
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
|
||||
router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
|
||||
// Everything else falls back on static content
|
||||
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
|
||||
return router
|
||||
}
|
||||
|
||||
func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc {
|
||||
if securityConfig != nil && securityConfig.IsValid() {
|
||||
return security.Handler(handler, securityConfig)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
58
controller/handler/handler_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateRouter(t *testing.T) {
|
||||
router := CreateRouter("../../web/static", nil, nil, true)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "health",
|
||||
Path: "/health",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Path: "/metrics",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "scripts",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "scripts-gzipped",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "index-redirect",
|
||||
Path: "/index.html",
|
||||
ExpectedCode: http.StatusMovedPermanently,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
27
controller/handler/spa.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v3/config/ui"
|
||||
)
|
||||
|
||||
func SinglePageApplication(staticFolder string, ui *ui.Config) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
t, err := template.ParseFiles(staticFolder + "/index.html")
|
||||
if err != nil {
|
||||
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
|
||||
http.ServeFile(writer, request, staticFolder+"/index.html")
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/html")
|
||||
err = t.Execute(writer, ui)
|
||||
if err != nil {
|
||||
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
|
||||
http.ServeFile(writer, request, staticFolder+"/index.html")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
70
controller/handler/spa_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v3/config"
|
||||
"github.com/TwiN/gatus/v3/core"
|
||||
"github.com/TwiN/gatus/v3/storage/store"
|
||||
"github.com/TwiN/gatus/v3/watchdog"
|
||||
)
|
||||
|
||||
func TestSinglePageApplication(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "frontend-endpoint",
|
||||
Path: "/endpoints/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{ // XXX: Remove this in v4.0.0
|
||||
Name: "frontend-service",
|
||||
Path: "/services/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package controller
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/TwinProduction/gatus/storage/store/common"
|
||||
"github.com/TwiN/gatus/v3/storage/store/common"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -1,4 +1,4 @@
|
||||
package controller
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -54,7 +54,7 @@ func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), nil)
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), http.NoBody)
|
||||
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(request)
|
||||
if actualPage != scenario.ExpectedPage {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
|
||||
@@ -1,8 +0,0 @@
|
||||
package controller
|
||||
|
||||
import "net/http"
|
||||
|
||||
// spaHandler handles requests for /
|
||||
func spaHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, staticFolder+"/index.html")
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwinProduction/gatus/jsonpath"
|
||||
"github.com/TwinProduction/gatus/pattern"
|
||||
"github.com/TwiN/gatus/v3/jsonpath"
|
||||
"github.com/TwiN/gatus/v3/pattern"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -80,49 +80,49 @@ const (
|
||||
maximumLengthBeforeTruncatingWhenComparedWithPattern = 25
|
||||
)
|
||||
|
||||
// Condition is a condition that needs to be met in order for a Service to be considered healthy.
|
||||
// Condition is a condition that needs to be met in order for an Endpoint to be considered healthy.
|
||||
type Condition string
|
||||
|
||||
// evaluate the Condition with the Result of the health check
|
||||
// TODO: Add a mandatory space between each operators (e.g. " == " instead of "==") (BREAKING CHANGE)
|
||||
func (c Condition) evaluate(result *Result) bool {
|
||||
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bool {
|
||||
condition := string(c)
|
||||
success := false
|
||||
conditionToDisplay := condition
|
||||
if strings.Contains(condition, "==") {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "=="), result)
|
||||
success = isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success {
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
|
||||
}
|
||||
} else if strings.Contains(condition, "!=") {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, "!="), result)
|
||||
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success {
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
|
||||
}
|
||||
} else if strings.Contains(condition, "<=") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<="), result)
|
||||
success = resolvedParameters[0] <= resolvedParameters[1]
|
||||
if !success {
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
|
||||
}
|
||||
} else if strings.Contains(condition, ">=") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">="), result)
|
||||
success = resolvedParameters[0] >= resolvedParameters[1]
|
||||
if !success {
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
|
||||
}
|
||||
} else if strings.Contains(condition, ">") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, ">"), result)
|
||||
success = resolvedParameters[0] > resolvedParameters[1]
|
||||
if !success {
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
|
||||
}
|
||||
} else if strings.Contains(condition, "<") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, "<"), result)
|
||||
success = resolvedParameters[0] < resolvedParameters[1]
|
||||
if !success {
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
|
||||
}
|
||||
} else {
|
||||
@@ -283,7 +283,7 @@ func prettifyNumericalParameters(parameters []string, resolvedParameters []int64
|
||||
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
|
||||
}
|
||||
|
||||
// XXX: make this configurable? i.e. show-resolved-conditions-on-failure
|
||||
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
||||
func prettify(parameters []string, resolvedParameters []string, operator string) string {
|
||||
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
|
||||
// we'll return the resolvedParameters as-is.
|
||||
|
||||
@@ -6,7 +6,7 @@ func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -15,7 +15,7 @@ func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
|
||||
condition := Condition("[BODY].user.name == bob.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{HTTPStatus: 200}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{HTTPStatus: 400}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
)
|
||||
|
||||
func TestCondition_evaluate(t *testing.T) {
|
||||
type scenario struct {
|
||||
Name string
|
||||
Condition Condition
|
||||
Result *Result
|
||||
ExpectedSuccess bool
|
||||
ExpectedOutput string
|
||||
}
|
||||
scenarios := []scenario{
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Condition Condition
|
||||
Result *Result
|
||||
DontResolveFailedConditions bool
|
||||
ExpectedSuccess bool
|
||||
ExpectedOutput string
|
||||
}{
|
||||
{
|
||||
Name: "ip",
|
||||
Condition: Condition("[IP] == 127.0.0.1"),
|
||||
@@ -372,6 +372,14 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] (404) == any(200, 429)",
|
||||
},
|
||||
{
|
||||
Name: "status-any-failure-but-dont-resolve",
|
||||
Condition: Condition("[STATUS] == any(200, 429)"),
|
||||
Result: &Result{HTTPStatus: 404},
|
||||
DontResolveFailedConditions: true,
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[STATUS] == any(200, 429)",
|
||||
},
|
||||
{
|
||||
Name: "connected",
|
||||
Condition: Condition("[CONNECTED] == true"),
|
||||
@@ -435,6 +443,14 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "has([BODY].errors) (true) == false",
|
||||
},
|
||||
{
|
||||
Name: "has-failure-but-dont-resolve",
|
||||
Condition: Condition("has([BODY].errors) == false"),
|
||||
Result: &Result{body: []byte("{\"errors\": [\"1\"]}")},
|
||||
DontResolveFailedConditions: true,
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "has([BODY].errors) == false",
|
||||
},
|
||||
{
|
||||
Name: "no-placeholders",
|
||||
Condition: Condition("1 == 2"),
|
||||
@@ -445,7 +461,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Condition.evaluate(scenario.Result)
|
||||
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions)
|
||||
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
|
||||
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
|
||||
}
|
||||
@@ -459,7 +475,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
||||
condition := Condition("[STATUS] ? 201")
|
||||
result := &Result{HTTPStatus: 201}
|
||||
condition.evaluate(result)
|
||||
condition.evaluate(result, false)
|
||||
if result.Success {
|
||||
t.Error("condition was invalid, result should've been a failure")
|
||||
}
|
||||
|
||||