mirror of
https://github.com/TwiN/gatus.git
synced 2026-09-21 07:14:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3873d0b4 | ||
|
|
4d15cb7eed | ||
|
|
08c463542f | ||
|
|
0fd2520088 | ||
|
|
7a73be52c5 | ||
|
|
73381e4179 | ||
|
|
b7100620c3 | ||
|
|
ae7ca199aa | ||
|
|
53392dd472 | ||
|
|
4566e746f3 | ||
|
|
08895200bb | ||
|
|
809e5068d7 | ||
|
|
ed1107b41a | ||
|
|
154ee6e055 | ||
|
|
9095a5254e | ||
|
|
fba833f64b | ||
|
|
834bfab5c5 | ||
|
|
f9e03cb2c3 | ||
|
|
760b75348e | ||
|
|
447c7ad321 | ||
|
|
bc433161d6 | ||
|
|
42b51f5da5 | ||
|
|
48056def12 | ||
|
|
f06c4f132b | ||
|
|
64116ce3da | ||
|
|
12be9facda | ||
|
|
f925cb4924 | ||
|
|
f4428aa600 | ||
|
|
0e390765e3 | ||
|
|
893601a5f4 | ||
|
|
322f278e8f | ||
|
|
814a9622c4 | ||
|
|
a2fc5ab9bb | ||
|
|
593826ec5f | ||
|
|
9b2efa290f | ||
|
|
714143204b | ||
|
|
ff396b5079 | ||
|
|
b20935ecfe | ||
|
|
3a6ad14cba | ||
|
|
e6e211c916 | ||
|
|
7cd09e7d03 | ||
|
|
fa8a387c9f | ||
|
|
c7f0b85ba1 | ||
|
|
c4900ced6c | ||
|
|
433e59b0ce | ||
|
|
f09d959c97 | ||
|
|
e5b4e0d381 | ||
|
|
e580cd362a | ||
|
|
b65b4d83e1 | ||
|
|
618892c248 | ||
|
|
77cedb080d | ||
|
|
786af2dee4 | ||
|
|
48531911bb | ||
|
|
720888009e | ||
|
|
961bffd8e7 | ||
|
|
309d68e622 | ||
|
|
ce269905b7 | ||
|
|
d8724d683e | ||
|
|
0f22e863a9 | ||
|
|
7bb959e072 | ||
|
|
1095deb3c6 | ||
|
|
c93a321073 | ||
|
|
1bf05d6bf9 | ||
|
|
1c532971cb | ||
|
|
67bf6c16b6 | ||
|
|
c94c9fa36a | ||
|
|
9587e5db8a | ||
|
|
2d5f0a5927 | ||
|
|
6c8761ca35 | ||
|
|
40b1576ec7 | ||
|
|
64c3b12a7b | ||
|
|
138f5bfb76 | ||
|
|
15a8055617 | ||
|
|
13184232d1 | ||
|
|
d0cca91043 | ||
|
|
239d1f5118 | ||
|
|
47bc78dc25 | ||
|
|
1df0801a61 | ||
|
|
d42c5f899e | ||
|
|
5f4c26e5fe | ||
|
|
2beaca5700 | ||
|
|
a55eb7da40 | ||
|
|
b0629773e5 | ||
|
|
c5f7e5b82b | ||
|
|
a2a7e1f14a | ||
|
|
1e4c440f01 | ||
|
|
844847bb05 | ||
|
|
0c3231713f | ||
|
|
ee01adb603 | ||
|
|
9121d87965 | ||
|
|
86cd1a9eb2 | ||
|
|
9f960fdd27 | ||
|
|
6f9db4107c | ||
|
|
5d626f2934 | ||
|
|
75c1b290f6 | ||
|
|
fe7b74f555 | ||
|
|
ed4c270a25 | ||
|
|
379ec2983d | ||
|
|
907716289c | ||
|
|
7c6b5539c1 | ||
|
|
607f3c5549 | ||
|
|
9e97efaba1 | ||
|
|
8912b4b3e3 | ||
|
|
5fdc489113 | ||
|
|
2ebb74ae1e | ||
|
|
e2f06e9ede | ||
|
|
beb9a2f3d9 | ||
|
|
e469b6adf4 | ||
|
|
2f8a3d2a02 | ||
|
|
9495b7389e | ||
|
|
c8bdecbde8 | ||
|
|
394602bc47 | ||
|
|
15813d4297 | ||
|
|
d24c66cf96 | ||
|
|
70d7d0c54c | ||
|
|
91931e48b4 | ||
|
|
386a4d2cb7 | ||
|
|
4d9eb0572c | ||
|
|
1586b3cc0b | ||
|
|
981e082d0c | ||
|
|
91daaf92aa | ||
|
|
a1bb07c556 | ||
|
|
258175dec3 | ||
|
|
ef6159e420 | ||
|
|
ebd4068aac | ||
|
|
39981de54b | ||
|
|
7dce07e47f | ||
|
|
6a83857db4 | ||
|
|
50702bd1d4 | ||
|
|
5bf95fe4f7 | ||
|
|
20d8ef966b | ||
|
|
8f15346fb7 | ||
|
|
8920bdd301 | ||
|
|
e37024dfc6 | ||
|
|
ac4374b1e3 | ||
|
|
129fb82f71 | ||
|
|
374be99b35 | ||
|
|
5c78bd92fb | ||
|
|
8853140cb2 | ||
|
|
03ec18a703 | ||
|
|
65eaed4621 | ||
|
|
10c6e71eef | ||
|
|
c7f0a32135 | ||
|
|
405c15f756 | ||
|
|
6f1312dfcf | ||
|
|
bd296c75da | ||
|
|
f007725140 |
+2
-3
@@ -1,4 +1,3 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
gatus:
|
gatus:
|
||||||
container_name: gatus
|
container_name: gatus
|
||||||
@@ -13,7 +12,7 @@ services:
|
|||||||
|
|
||||||
prometheus:
|
prometheus:
|
||||||
container_name: prometheus
|
container_name: prometheus
|
||||||
image: prom/prometheus:v2.14.0
|
image: prom/prometheus:v3.5.0
|
||||||
restart: always
|
restart: always
|
||||||
command: --config.file=/etc/prometheus/prometheus.yml
|
command: --config.file=/etc/prometheus/prometheus.yml
|
||||||
ports:
|
ports:
|
||||||
@@ -25,7 +24,7 @@ services:
|
|||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
container_name: grafana
|
container_name: grafana
|
||||||
image: grafana/grafana:6.4.4
|
image: grafana/grafana:12.1.0
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
GF_SECURITY_ADMIN_PASSWORD: secret
|
GF_SECURITY_ADMIN_PASSWORD: secret
|
||||||
@@ -17,3 +17,9 @@ endpoints:
|
|||||||
interval: 5m
|
interval: 5m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: check-domain-expiration
|
||||||
|
url: "https://example.org/"
|
||||||
|
interval: 1h
|
||||||
|
conditions:
|
||||||
|
- "[DOMAIN_EXPIRATION] > 720h"
|
||||||
+265
-128
@@ -19,7 +19,7 @@
|
|||||||
"editable": true,
|
"editable": true,
|
||||||
"fiscalYearStartMonth": 0,
|
"fiscalYearStartMonth": 0,
|
||||||
"graphTooltip": 0,
|
"graphTooltip": 0,
|
||||||
"id": 566,
|
"id": 41,
|
||||||
"links": [],
|
"links": [],
|
||||||
"panels": [
|
"panels": [
|
||||||
{
|
{
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "green"
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "yellow",
|
"color": "yellow",
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
"textMode": "auto",
|
"textMode": "auto",
|
||||||
"wideLayout": true
|
"wideLayout": true
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -122,7 +123,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "red"
|
"color": "red",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "yellow",
|
"color": "yellow",
|
||||||
@@ -162,7 +164,7 @@
|
|||||||
"textMode": "auto",
|
"textMode": "auto",
|
||||||
"wideLayout": true
|
"wideLayout": true
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -193,7 +195,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "green"
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -225,7 +228,7 @@
|
|||||||
"textMode": "auto",
|
"textMode": "auto",
|
||||||
"wideLayout": true
|
"wideLayout": true
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -292,7 +295,7 @@
|
|||||||
"sort": "none"
|
"sort": "none"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -321,7 +324,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "$datasource"
|
"uid": "$datasource"
|
||||||
},
|
},
|
||||||
"description": "SSL certificate expiration times for all services",
|
"description": "Domain expiration times for all domains",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -339,7 +342,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "red"
|
"color": "red",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "#EAB839",
|
"color": "#EAB839",
|
||||||
@@ -395,7 +399,137 @@
|
|||||||
"showHeader": true,
|
"showHeader": true,
|
||||||
"sortBy": []
|
"sortBy": []
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "$datasource"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "gatus_results_domain_expiration_seconds",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "__auto",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Domain Expiration",
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "organize",
|
||||||
|
"options": {
|
||||||
|
"excludeByName": {
|
||||||
|
"Time": true,
|
||||||
|
"Value": false,
|
||||||
|
"__name__": true,
|
||||||
|
"app_kubernetes_io_instance": true,
|
||||||
|
"app_kubernetes_io_managed_by": true,
|
||||||
|
"app_kubernetes_io_name": true,
|
||||||
|
"app_kubernetes_io_service": true,
|
||||||
|
"helm_sh_chart": true,
|
||||||
|
"instance": true,
|
||||||
|
"job": true,
|
||||||
|
"key": true,
|
||||||
|
"type": true
|
||||||
|
},
|
||||||
|
"includeByName": {},
|
||||||
|
"indexByName": {
|
||||||
|
"Value": 2,
|
||||||
|
"group": 0,
|
||||||
|
"name": 1
|
||||||
|
},
|
||||||
|
"renameByName": {
|
||||||
|
"Value": "Time Until Expiry",
|
||||||
|
"group": "Group",
|
||||||
|
"name": "Service"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "$datasource"
|
||||||
|
},
|
||||||
|
"description": "SSL certificate expiration times for all services",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#EAB839",
|
||||||
|
"value": 172800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 604800
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "dtdurations"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Time Until Expiry"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.cellOptions",
|
||||||
|
"value": {
|
||||||
|
"applyToRow": false,
|
||||||
|
"type": "color-background"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unit",
|
||||||
|
"value": "s"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 8
|
||||||
|
},
|
||||||
|
"id": 11,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": []
|
||||||
|
},
|
||||||
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -444,6 +578,113 @@
|
|||||||
],
|
],
|
||||||
"type": "table"
|
"type": "table"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "$datasource"
|
||||||
|
},
|
||||||
|
"description": "Current status distribution across all services",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"axisBorderShow": false,
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisColorMode": "text",
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"barWidthFactor": 0.6,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"gradientMode": "none",
|
||||||
|
"hideFrom": {
|
||||||
|
"legend": false,
|
||||||
|
"tooltip": false,
|
||||||
|
"vis": false,
|
||||||
|
"viz": false
|
||||||
|
},
|
||||||
|
"insertNulls": false,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"type": "linear"
|
||||||
|
},
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
},
|
||||||
|
"thresholdsStyle": {
|
||||||
|
"mode": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"id": 5,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [],
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"hideZeros": false,
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginVersion": "12.1.0",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "$datasource"
|
||||||
|
},
|
||||||
|
"expr": "sum(gatus_results_endpoint_success)",
|
||||||
|
"legendFormat": "Services UP",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "$datasource"
|
||||||
|
},
|
||||||
|
"expr": "sum(1 - gatus_results_endpoint_success)",
|
||||||
|
"legendFormat": "Services DOWN",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Service Status Distribution",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
@@ -483,7 +724,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "red"
|
"color": "red",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "green",
|
"color": "green",
|
||||||
@@ -525,7 +767,7 @@
|
|||||||
"h": 8,
|
"h": 8,
|
||||||
"w": 12,
|
"w": 12,
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 8
|
"y": 16
|
||||||
},
|
},
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -546,7 +788,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -689,7 +931,8 @@
|
|||||||
"mode": "absolute",
|
"mode": "absolute",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"color": "green"
|
"color": "green",
|
||||||
|
"value": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"color": "red",
|
"color": "red",
|
||||||
@@ -703,9 +946,9 @@
|
|||||||
},
|
},
|
||||||
"gridPos": {
|
"gridPos": {
|
||||||
"h": 8,
|
"h": 8,
|
||||||
"w": 12,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 16
|
"y": 24
|
||||||
},
|
},
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -721,7 +964,7 @@
|
|||||||
"sort": "none"
|
"sort": "none"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pluginVersion": "12.0.2",
|
"pluginVersion": "12.1.0",
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -735,112 +978,6 @@
|
|||||||
],
|
],
|
||||||
"title": "Response Times",
|
"title": "Response Times",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "$datasource"
|
|
||||||
},
|
|
||||||
"description": "Current status distribution across all services",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {
|
|
||||||
"mode": "palette-classic"
|
|
||||||
},
|
|
||||||
"custom": {
|
|
||||||
"axisBorderShow": false,
|
|
||||||
"axisCenteredZero": false,
|
|
||||||
"axisColorMode": "text",
|
|
||||||
"axisLabel": "",
|
|
||||||
"axisPlacement": "auto",
|
|
||||||
"barAlignment": 0,
|
|
||||||
"barWidthFactor": 0.6,
|
|
||||||
"drawStyle": "line",
|
|
||||||
"fillOpacity": 10,
|
|
||||||
"gradientMode": "none",
|
|
||||||
"hideFrom": {
|
|
||||||
"legend": false,
|
|
||||||
"tooltip": false,
|
|
||||||
"vis": false,
|
|
||||||
"viz": false
|
|
||||||
},
|
|
||||||
"insertNulls": false,
|
|
||||||
"lineInterpolation": "linear",
|
|
||||||
"lineWidth": 1,
|
|
||||||
"pointSize": 5,
|
|
||||||
"scaleDistribution": {
|
|
||||||
"type": "linear"
|
|
||||||
},
|
|
||||||
"showPoints": "never",
|
|
||||||
"spanNulls": false,
|
|
||||||
"stacking": {
|
|
||||||
"group": "A",
|
|
||||||
"mode": "none"
|
|
||||||
},
|
|
||||||
"thresholdsStyle": {
|
|
||||||
"mode": "off"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mappings": [],
|
|
||||||
"thresholds": {
|
|
||||||
"mode": "absolute",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"color": "green"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"color": "red",
|
|
||||||
"value": 80
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"unit": "short"
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 12,
|
|
||||||
"x": 12,
|
|
||||||
"y": 16
|
|
||||||
},
|
|
||||||
"id": 5,
|
|
||||||
"options": {
|
|
||||||
"legend": {
|
|
||||||
"calcs": [],
|
|
||||||
"displayMode": "list",
|
|
||||||
"placement": "bottom",
|
|
||||||
"showLegend": true
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"hideZeros": false,
|
|
||||||
"mode": "single",
|
|
||||||
"sort": "none"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pluginVersion": "12.0.2",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "$datasource"
|
|
||||||
},
|
|
||||||
"expr": "sum(gatus_results_endpoint_success)",
|
|
||||||
"legendFormat": "Services UP",
|
|
||||||
"refId": "A"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": {
|
|
||||||
"type": "prometheus",
|
|
||||||
"uid": "$datasource"
|
|
||||||
},
|
|
||||||
"expr": "sum(1 - gatus_results_endpoint_success)",
|
|
||||||
"legendFormat": "Services DOWN",
|
|
||||||
"refId": "B"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Service Status Distribution",
|
|
||||||
"type": "timeseries"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"preload": false,
|
"preload": false,
|
||||||
@@ -855,8 +992,8 @@
|
|||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"current": {
|
"current": {
|
||||||
"text": "myprom",
|
"text": "prometheus",
|
||||||
"value": "PA04845DA3A4B088E"
|
"value": "cedv077q7bbwgd"
|
||||||
},
|
},
|
||||||
"description": "Select your Prometheus datasource",
|
"description": "Select your Prometheus datasource",
|
||||||
"includeAll": false,
|
"includeAll": false,
|
||||||
@@ -877,6 +1014,6 @@
|
|||||||
"timepicker": {},
|
"timepicker": {},
|
||||||
"timezone": "",
|
"timezone": "",
|
||||||
"title": "Gatus - Service Monitoring Dashboard",
|
"title": "Gatus - Service Monitoring Dashboard",
|
||||||
"uid": "gatus-monitoring2",
|
"uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
|
||||||
"version": 10
|
"version": 1
|
||||||
}
|
}
|
||||||
-1
@@ -1,4 +1,3 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
gatus:
|
gatus:
|
||||||
container_name: gatus
|
container_name: gatus
|
||||||
-1
@@ -1,4 +1,3 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:stable
|
image: nginx:stable
|
||||||
-1
@@ -1,4 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
services:
|
services:
|
||||||
gatus:
|
gatus:
|
||||||
image: twinproduction/gatus:latest
|
image: twinproduction/gatus:latest
|
||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres
|
image: postgres
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/db:/var/lib/postgresql/data
|
# PostgreSQL 18 and above declare the volume at /var/lib/postgresql.
|
||||||
|
# For 17 and earlier, mount ./data/db at /var/lib/postgresql/data instead.
|
||||||
|
- ./data/db:/var/lib/postgresql
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
-1
@@ -1,4 +1,3 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
gatus:
|
gatus:
|
||||||
image: twinproduction/gatus:latest
|
image: twinproduction/gatus:latest
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
services:
|
services:
|
||||||
gatus:
|
gatus:
|
||||||
image: twinproduction/gatus:latest
|
image: twinproduction/gatus:latest
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 89.76 89.75"><defs><style>.cls-1{fill:#3cad4b;}.cls-2{fill:#017400;}.cls-3{fill:#1e9025;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M33.67,65.35a23.35,23.35,0,0,1,.08-41,22.94,22.94,0,0,1,3.8-1.64A23,23,0,0,0,53.6,1C53,0,51,0,44.89,0c-9.08,0-9.21.17-8.81,3.22,1.07,8.12-9.42,12.5-14.45,6-1.94-2.52-2.1-2.52-8.68,4.16-6.22,6.3-6.33,6.28-3.77,8.25a8.09,8.09,0,0,1,2.56,9.53A8.15,8.15,0,0,1,3.08,36C0,35.63,0,35.73,0,45.2.08,53.81,0,54,3.3,53.63A8.06,8.06,0,0,1,9.76,67.52c-3,2.83-2.84,2.61,2.84,8.48,5.43,5.62,6.33,6.73,8.16,5.24L34,68A1.63,1.63,0,0,0,33.67,65.35Z"/><path class="cls-2" d="M85.43,36.13a8.11,8.11,0,0,1-5.27-14.21c2.85-2.5,2.82-2.37-3.55-8.75-4.31-4.31-5.71-5.75-6.87-5.4l-14,14a1.65,1.65,0,0,0,.36,2.61,23.35,23.35,0,0,1-.1,41,24.5,24.5,0,0,1-5.11,2c-8.54,2.28-14.73,9.63-14.73,18.47v1.27c.15,2.54,1.19,2.42,8.06,2.52,9.32.14,9.1.35,9.38-4.66a8.11,8.11,0,0,1,14-5.09c3,3.15,2.39,3.11,8.73-3.14,6.56-6.47,6.86-6.25,3.68-9.14a8.1,8.1,0,0,1,6.06-14.07c3.68.27,3.51.06,3.63-8.09C89.85,36.27,90,36.16,85.43,36.13Z"/><path class="cls-3" d="M41.11,59h8a.76.76,0,0,0,.77-.76V50.43a.76.76,0,0,1,.77-.76h7.84a.78.78,0,0,0,.77-.77V40.84a.77.77,0,0,0-.77-.76H50.7a.76.76,0,0,1-.77-.77V31.47a.76.76,0,0,0-.77-.77h-8a.76.76,0,0,0-.77.77v7.84a.76.76,0,0,1-.77.77H31.73a.77.77,0,0,0-.77.76V48.9a.78.78,0,0,0,.77.77h7.84a.76.76,0,0,1,.77.76v7.85A.76.76,0,0,0,41.11,59Z"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -1,15 +0,0 @@
|
|||||||
ignore:
|
|
||||||
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
|
||||||
- "watchdog/endpoint.go"
|
|
||||||
- "watchdog/external_endpoint.go"
|
|
||||||
- "watchdog/suite.go"
|
|
||||||
- "watchdog/watchdog.go"
|
|
||||||
comment: false
|
|
||||||
coverage:
|
|
||||||
status:
|
|
||||||
patch: off
|
|
||||||
project:
|
|
||||||
default:
|
|
||||||
target: 70%
|
|
||||||
threshold: null
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
See AGENTS.md for agent instructions
|
||||||
@@ -22,9 +22,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 1.24.4
|
go-version: 1.26.3
|
||||||
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||||
ref: "${{ github.event.inputs.ref || 'master' }}"
|
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- name: Benchmark
|
- name: Benchmark
|
||||||
run: go test -bench=. ./storage/store
|
run: go test -bench=. ./storage/store
|
||||||
|
|||||||
@@ -5,35 +5,44 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: Custom tag to publish
|
description: Custom tag to publish
|
||||||
|
platforms:
|
||||||
|
description: Platforms to publish to (comma separated list)
|
||||||
|
default: linux/amd64
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm/v7
|
||||||
|
- linux/arm64
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-custom:
|
publish-custom:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ env.GHCR_IMAGE_REPOSITORY }}
|
images: ${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=${{ inputs.tag }}
|
type=raw,value=${{ inputs.tag }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: ${{ inputs.platforms }}
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -5,27 +5,27 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE_REPOSITORY }}
|
images: ${{ env.IMAGE_REPOSITORY }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=experimental
|
type=raw,value=experimental
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
pull: true
|
pull: true
|
||||||
|
|||||||
@@ -13,29 +13,29 @@ jobs:
|
|||||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||||
timeout-minutes: 240
|
timeout-minutes: 240
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: |
|
run: |
|
||||||
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 240
|
timeout-minutes: 240
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: |
|
run: |
|
||||||
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
@@ -20,19 +20,19 @@ jobs:
|
|||||||
- name: Get the release
|
- name: Get the release
|
||||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
type=raw,value=stable
|
type=raw,value=stable
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
name: regenerate-static-assets
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-command:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.issue.pull_request }}
|
||||||
|
permissions:
|
||||||
|
pull-requests: write # required for adding reactions to command comments on PRs
|
||||||
|
checks: read # required to check if all ci checks have passed
|
||||||
|
outputs:
|
||||||
|
continue: ${{ steps.command.outputs.continue }}
|
||||||
|
steps:
|
||||||
|
- name: Check command trigger
|
||||||
|
id: command
|
||||||
|
uses: github/command@v2
|
||||||
|
with:
|
||||||
|
command: "/regenerate-static-assets"
|
||||||
|
permissions: "write,admin" # The allowed permission levels to invoke this command
|
||||||
|
allow_forks: true
|
||||||
|
allow_drafts: true
|
||||||
|
skip_ci: true
|
||||||
|
skip_completing: true
|
||||||
|
|
||||||
|
regenerate-static-assets:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check-command
|
||||||
|
if: ${{ needs.check-command.outputs.continue == 'true' }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
outputs:
|
||||||
|
status: ${{ steps.commit.outputs.status }}
|
||||||
|
steps:
|
||||||
|
- name: Get PR branch
|
||||||
|
id: pr
|
||||||
|
uses: actions/github-script@v9
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const pr = await github.rest.pulls.get({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: context.issue.number
|
||||||
|
});
|
||||||
|
core.setOutput('ref', pr.data.head.ref);
|
||||||
|
core.setOutput('repo', pr.data.head.repo.full_name);
|
||||||
|
- name: Checkout PR branch
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.pr.outputs.repo }}
|
||||||
|
ref: ${{ steps.pr.outputs.ref }}
|
||||||
|
- name: Regenerate static assets
|
||||||
|
run: |
|
||||||
|
make frontend-install
|
||||||
|
make frontend-build
|
||||||
|
- name: Commit and push changes
|
||||||
|
id: commit
|
||||||
|
run: |
|
||||||
|
echo "Checking for changes..."
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "No changes detected."
|
||||||
|
echo "status=no_changes" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
echo "Changes detected. Committing and pushing..."
|
||||||
|
git add .
|
||||||
|
git commit -m "chore(ui): Regenerate static assets"
|
||||||
|
git push origin ${{ steps.pr.outputs.ref }}
|
||||||
|
echo "status=success" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
create-response-comment:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-command, regenerate-static-assets]
|
||||||
|
if: ${{ !cancelled() && needs.check-command.outputs.continue == 'true' }}
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Create response comment
|
||||||
|
uses: actions/github-script@v9
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const status = '${{ needs.regenerate-static-assets.outputs.status }}';
|
||||||
|
let reaction = 'hooray';
|
||||||
|
let message = '';
|
||||||
|
var workflowUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
|
||||||
|
if (status === 'no_changes') {
|
||||||
|
message = `@${context.actor} No changes to commit ([ref](${workflowUrl})).`;
|
||||||
|
} else if (status === 'success') {
|
||||||
|
// Assets regenerated successfully - no comment needed, just add reaction
|
||||||
|
} else {
|
||||||
|
reaction = '-1';
|
||||||
|
message = `@${context.actor} There was an issue regenerating static assets. Please check the [workflow run logs](${workflowUrl}) for more details.`;
|
||||||
|
}
|
||||||
|
if (message.length) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await github.rest.reactions.createForIssueComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: context.payload.comment.id,
|
||||||
|
content: reaction
|
||||||
|
});
|
||||||
@@ -13,6 +13,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- run: make frontend-install-dependencies
|
- run: make frontend-install
|
||||||
- run: make frontend-build
|
- run: make frontend-build
|
||||||
@@ -18,17 +18,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 1.24.4
|
go-version: 1.26.3
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- name: Build binary to make sure it works
|
- name: Build binary to make sure it works
|
||||||
run: go build
|
run: go build
|
||||||
- name: Test
|
- name: Test
|
||||||
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
|
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
|
||||||
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
|
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
|
||||||
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
|
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race
|
||||||
- name: Codecov
|
|
||||||
uses: codecov/codecov-action@v5.5.1
|
|
||||||
with:
|
|
||||||
files: ./coverage.txt
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Guidance for AI coding agents working in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `make install` — Build the `gatus` binary
|
||||||
|
- `make run` — Run in dev mode (uses `./config.yaml`)
|
||||||
|
- `make test` — Run all Go tests with coverage
|
||||||
|
- `make clean` — Remove built binary
|
||||||
|
- `make frontend-install` — Install frontend npm dependencies
|
||||||
|
- `make frontend-build` — Build the Vue.js frontend (**must run after any `web/` changes**)
|
||||||
|
- `make frontend-dev` — Start the Vue dev server
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- **Vendored dependencies**: After adding/updating a Go dependency, run `go mod tidy && go mod vendor`
|
||||||
|
- **Frontend build artifacts**: `web/static/` is generated by `make frontend-build` and embedded into the Go binary at compile time via `//go:embed`. Never edit files in `web/static/` directly
|
||||||
|
- **Config validation order**: In `config/config.go` `parseAndValidateConfigBytes`, `ValidateAlertingConfig` **must** run before `ValidateEndpointsConfig` because provider default alerts must be parsed before endpoint defaults are set
|
||||||
|
- **Invalid configs panic at startup** — validation errors are fatal, not graceful
|
||||||
|
- **Hot-reload**: `main.go` watches the config file and re-runs the full stop → save → load → init → start cycle. New startup logic must account for this
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `GATUS_CONFIG_PATH` — Config file path (default: `config/config.yaml`); can be a directory of YAML files (deep-merged)
|
||||||
|
- `GATUS_LOG_LEVEL` — `DEBUG`, `INFO`, `WARN`, `ERROR`
|
||||||
|
- `ENVIRONMENT=dev` — Enables CORS for frontend dev on `localhost:8081`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `main.go` — Entry point; loads config, initializes storage, starts watchdog + API server
|
||||||
|
- `config/` — Central config struct (`config.go`) and sub-packages per config section (each with `ValidateAndSetDefaults()`)
|
||||||
|
- `alerting/` — Alerting config (`config.go`), alert types (`alert/type.go`), and one sub-package per provider under `provider/`
|
||||||
|
- `api/` — Fiber-based HTTP API routes and SPA serving
|
||||||
|
- `watchdog/` — Monitoring loop; spawns a goroutine per endpoint with semaphore-based concurrency control
|
||||||
|
- `client/` — HTTP/gRPC client used for health checks
|
||||||
|
- `storage/store/` — Storage abstraction with `memory/`, `sql/` (SQLite + PostgreSQL) implementations
|
||||||
|
- `security/` — Authentication (basic + OIDC) and security middleware
|
||||||
|
- `web/app/` — Vue 3 frontend source; builds to `web/static/` (embedded into the Go binary)
|
||||||
|
- `vendor/` — Vendored Go dependencies (committed to repo)
|
||||||
|
|
||||||
|
## Common Changes
|
||||||
|
|
||||||
|
### Adding an Alerting Provider
|
||||||
|
|
||||||
|
Use `alerting/provider/slack/` as the reference implementation. Every new provider touches **6 locations**:
|
||||||
|
|
||||||
|
1. **Create provider package** — `alerting/provider/{name}/{name}.go` + `{name}_test.go`
|
||||||
|
- `Config` struct with `Validate()` and `Merge(*Config)` methods
|
||||||
|
- `AlertProvider` struct with `DefaultConfig` (yaml inline), `DefaultAlert *alert.Alert`, `Overrides []Override`
|
||||||
|
- Use `client.GetHTTPClient(nil)` (not `http.DefaultClient`), always `defer response.Body.Close()`
|
||||||
|
2. **Register type** — Add `Type{Name} Type = "{name}"` in `alerting/alert/type.go`
|
||||||
|
3. **Add to config struct** — Add field to `alerting.Config` in `alerting/config.go`; the **YAML tag must exactly match** the `alert.Type` string (reflection-based lookup via `GetAlertingProviderByAlertType`)
|
||||||
|
4. **Add compile-time checks** — Both `_ AlertProvider = (*pkg.AlertProvider)(nil)` **and** `_ Config[pkg.Config] = (*pkg.Config)(nil)` in `alerting/provider/provider.go`
|
||||||
|
5. **Register in validation** — Add the type to the `alertTypes` slice in `config/config.go` `ValidateAlertingConfig`
|
||||||
|
6. **Document** — Add to README.md in alphabetical order
|
||||||
|
|
||||||
|
### Adding a Config Section
|
||||||
|
|
||||||
|
1. Create package in `config/` with struct + `ValidateAndSetDefaults()` method
|
||||||
|
2. Add field to `Config` struct in `config/config.go`
|
||||||
|
3. Add `Validate*` call in `parseAndValidateConfigBytes` — **respect ordering constraints** (see comments in that function)
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- Vue 3 Composition API (`<script setup>`) + Tailwind CSS
|
||||||
|
- Props-based data flow — **do not use provide/inject**
|
||||||
|
- All components must include `dark:` prefixed Tailwind classes for dark mode
|
||||||
|
- `window.config` contains server-injected UI settings (Go template placeholders)
|
||||||
|
|
||||||
|
## API Routes (`api/api.go`)
|
||||||
|
|
||||||
|
- Unprotected routes are registered **before** `ApplySecurityMiddleware`
|
||||||
|
- Protected routes are registered **after** — placement order matters
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Config structs should have `*_test.go` files
|
||||||
|
- Use `t.Parallel()` where appropriate
|
||||||
|
- API tests spin up test servers (see existing `*_test.go` in `api/`)
|
||||||
|
|
||||||
|
## Commits & PRs
|
||||||
|
|
||||||
|
When creating a commit or PR as an agent, state that it was made by an agent and include your model name and version.
|
||||||
+1
-1
@@ -3,7 +3,7 @@ FROM golang:alpine AS builder
|
|||||||
RUN apk --update add ca-certificates
|
RUN apk --update add ca-certificates
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN go mod tidy
|
RUN go mod tidy -diff
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
||||||
|
|
||||||
# Run Tests inside docker image if you don't have a configured go environment
|
# Run Tests inside docker image if you don't have a configured go environment
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ test:
|
|||||||
# Docker #
|
# Docker #
|
||||||
##########
|
##########
|
||||||
|
|
||||||
|
.PHONY: docker-build
|
||||||
docker-build:
|
docker-build:
|
||||||
docker build -t twinproduction/gatus:latest .
|
docker build -t twinproduction/gatus:latest .
|
||||||
|
|
||||||
|
.PHONY: docker-run
|
||||||
docker-run:
|
docker-run:
|
||||||
docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
|
docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
|
||||||
|
|
||||||
|
.PHONY: docker-build-and-run
|
||||||
docker-build-and-run: docker-build docker-run
|
docker-build-and-run: docker-build docker-run
|
||||||
|
|
||||||
|
|
||||||
@@ -38,11 +41,14 @@ docker-build-and-run: docker-build docker-run
|
|||||||
# Front end #
|
# Front end #
|
||||||
#############
|
#############
|
||||||
|
|
||||||
frontend-install-dependencies:
|
.PHONY: frontend-install
|
||||||
|
frontend-install:
|
||||||
npm --prefix web/app install
|
npm --prefix web/app install
|
||||||
|
|
||||||
|
.PHONY: frontend-build
|
||||||
frontend-build:
|
frontend-build:
|
||||||
npm --prefix web/app run build
|
npm --prefix web/app run build
|
||||||
|
|
||||||
frontend-run:
|
.PHONY: frontend-dev
|
||||||
|
frontend-dev:
|
||||||
npm --prefix web/app run serve
|
npm --prefix web/app run serve
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||

|

|
||||||
[](https://goreportcard.com/report/github.com/TwiN/gatus)
|
[](https://goreportcard.com/report/github.com/TwiN/gatus)
|
||||||
[](https://codecov.io/gh/TwiN/gatus)
|
|
||||||
[](https://github.com/TwiN/gatus)
|
[](https://github.com/TwiN/gatus)
|
||||||
[](https://cloud.docker.com/repository/docker/twinproduction/gatus)
|
[](https://cloud.docker.com/repository/docker/twinproduction/gatus)
|
||||||
[](https://github.com/TwiN)
|
[](https://github.com/TwiN)
|
||||||
@@ -20,13 +19,14 @@ _Looking for a managed solution? Check out [Gatus.io](https://gatus.io)._
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>Quick start</b></summary>
|
<summary><b>Quick start</b></summary>
|
||||||
|
|
||||||
```console
|
|
||||||
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
|
|
||||||
```
|
|
||||||
You can also use GitHub Container Registry if you prefer:
|
|
||||||
```console
|
```console
|
||||||
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
|
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can also use Docker Hub if you prefer:
|
||||||
|
```console
|
||||||
|
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
|
||||||
|
```
|
||||||
For more details, see [Usage](#usage)
|
For more details, see [Usage](#usage)
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -49,11 +49,15 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
|||||||
- [Conditions](#conditions)
|
- [Conditions](#conditions)
|
||||||
- [Placeholders](#placeholders)
|
- [Placeholders](#placeholders)
|
||||||
- [Functions](#functions)
|
- [Functions](#functions)
|
||||||
|
- [Web](#web)
|
||||||
|
- [UI](#ui)
|
||||||
|
- [Announcements](#announcements)
|
||||||
- [Storage](#storage)
|
- [Storage](#storage)
|
||||||
- [Client configuration](#client-configuration)
|
- [Client configuration](#client-configuration)
|
||||||
- [Tunneling](#tunneling)
|
- [Tunneling](#tunneling)
|
||||||
- [Alerting](#alerting)
|
- [Alerting](#alerting)
|
||||||
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
|
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
|
||||||
|
- [Configuring ClickUp alerts](#configuring-clickup-alerts)
|
||||||
- [Configuring Datadog alerts](#configuring-datadog-alerts)
|
- [Configuring Datadog alerts](#configuring-datadog-alerts)
|
||||||
- [Configuring Discord alerts](#configuring-discord-alerts)
|
- [Configuring Discord alerts](#configuring-discord-alerts)
|
||||||
- [Configuring Email alerts](#configuring-email-alerts)
|
- [Configuring Email alerts](#configuring-email-alerts)
|
||||||
@@ -66,11 +70,11 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
|||||||
- [Configuring IFTTT alerts](#configuring-ifttt-alerts)
|
- [Configuring IFTTT alerts](#configuring-ifttt-alerts)
|
||||||
- [Configuring Ilert alerts](#configuring-ilert-alerts)
|
- [Configuring Ilert alerts](#configuring-ilert-alerts)
|
||||||
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
|
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
|
||||||
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
|
|
||||||
- [Configuring Line alerts](#configuring-line-alerts)
|
- [Configuring Line alerts](#configuring-line-alerts)
|
||||||
- [Configuring Matrix alerts](#configuring-matrix-alerts)
|
- [Configuring Matrix alerts](#configuring-matrix-alerts)
|
||||||
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
||||||
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
|
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
|
||||||
|
- [Configuring n8n alerts](#configuring-n8n-alerts)
|
||||||
- [Configuring New Relic alerts](#configuring-new-relic-alerts)
|
- [Configuring New Relic alerts](#configuring-new-relic-alerts)
|
||||||
- [Configuring Ntfy alerts](#configuring-ntfy-alerts)
|
- [Configuring Ntfy alerts](#configuring-ntfy-alerts)
|
||||||
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
|
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
|
||||||
@@ -94,7 +98,6 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
|||||||
- [Configuring Zulip alerts](#configuring-zulip-alerts)
|
- [Configuring Zulip alerts](#configuring-zulip-alerts)
|
||||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||||
- [Setting a default alert](#setting-a-default-alert)
|
- [Setting a default alert](#setting-a-default-alert)
|
||||||
- [Announcements](#announcements)
|
|
||||||
- [Maintenance](#maintenance)
|
- [Maintenance](#maintenance)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Basic Authentication](#basic-authentication)
|
- [Basic Authentication](#basic-authentication)
|
||||||
@@ -108,6 +111,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
|||||||
- [Docker](#docker)
|
- [Docker](#docker)
|
||||||
- [Helm Chart](#helm-chart)
|
- [Helm Chart](#helm-chart)
|
||||||
- [Terraform](#terraform)
|
- [Terraform](#terraform)
|
||||||
|
- [Kubernetes](#kubernetes)
|
||||||
- [Running the tests](#running-the-tests)
|
- [Running the tests](#running-the-tests)
|
||||||
- [Using in Production](#using-in-production)
|
- [Using in Production](#using-in-production)
|
||||||
- [FAQ](#faq)
|
- [FAQ](#faq)
|
||||||
@@ -118,6 +122,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
|||||||
- [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)
|
- [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)
|
||||||
- [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint)
|
- [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint)
|
||||||
- [Monitoring a WebSocket endpoint](#monitoring-a-websocket-endpoint)
|
- [Monitoring a WebSocket endpoint](#monitoring-a-websocket-endpoint)
|
||||||
|
- [Monitoring an endpoint using gRPC](#monitoring-an-endpoint-using-grpc)
|
||||||
- [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp)
|
- [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp)
|
||||||
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
|
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
|
||||||
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
|
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
|
||||||
@@ -130,6 +135,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
|||||||
- [How do I sort by group by default?](#how-do-i-sort-by-group-by-default)
|
- [How do I sort by group by default?](#how-do-i-sort-by-group-by-default)
|
||||||
- [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path)
|
- [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path)
|
||||||
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
|
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
|
||||||
|
- [Use environment variables in config files](#use-environment-variables-in-config-files)
|
||||||
- [Configuring a startup delay](#configuring-a-startup-delay)
|
- [Configuring a startup delay](#configuring-a-startup-delay)
|
||||||
- [Keeping your configuration small](#keeping-your-configuration-small)
|
- [Keeping your configuration small](#keeping-your-configuration-small)
|
||||||
- [Proxy client configuration](#proxy-client-configuration)
|
- [Proxy client configuration](#proxy-client-configuration)
|
||||||
@@ -185,18 +191,15 @@ The main features of Gatus are:
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Quick start</b></summary>
|
|
||||||
|
|
||||||
```console
|
```console
|
||||||
docker run -p 8080:8080 --name gatus twinproduction/gatus
|
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
|
||||||
```
|
```
|
||||||
You can also use GitHub Container Registry if you prefer:
|
|
||||||
|
You can also use Docker Hub if you prefer:
|
||||||
```console
|
```console
|
||||||
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus
|
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
|
||||||
```
|
```
|
||||||
If you want to create your own configuration, see [Docker](#docker) for information on how to mount a configuration file.
|
If you want to create your own configuration, see [Docker](#docker) for information on how to mount a configuration file.
|
||||||
</details>
|
|
||||||
|
|
||||||
Here's a simple example:
|
Here's a simple example:
|
||||||
```yaml
|
```yaml
|
||||||
@@ -221,6 +224,9 @@ This example would look similar to this:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
If you want to test it locally, see [Docker](#docker).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
By default, the configuration file is expected to be at `config/config.yaml`.
|
By default, the configuration file is expected to be at `config/config.yaml`.
|
||||||
|
|
||||||
You can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable.
|
You can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable.
|
||||||
@@ -234,16 +240,16 @@ subdirectories are merged like so:
|
|||||||
|
|
||||||
> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
|
> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
|
||||||
>
|
>
|
||||||
> See [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for an example.
|
> ⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
|
||||||
>
|
>
|
||||||
> When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
|
> See [Use environment variables in config files](#use-environment-variables-in-config-files) or [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for examples.
|
||||||
|
|
||||||
If you want to test it locally, see [Docker](#docker).
|
If you want to test it locally, see [Docker](#docker).
|
||||||
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------------|
|
||||||
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
||||||
| `storage` | [Storage configuration](#storage). | `{}` |
|
| `storage` | [Storage configuration](#storage). | `{}` |
|
||||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||||
@@ -254,25 +260,8 @@ If you want to test it locally, see [Docker](#docker).
|
|||||||
| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` |
|
| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` |
|
||||||
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` |
|
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` |
|
||||||
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
|
||||||
| `web` | Web configuration. | `{}` |
|
| `web` | [Web configuration](#web). | `{}` |
|
||||||
| `web.address` | Address to listen on. | `0.0.0.0` |
|
| `ui` | [UI configuration](#ui). | `{}` |
|
||||||
| `web.port` | Port to listen on. | `8080` |
|
|
||||||
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
|
|
||||||
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
|
|
||||||
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
|
|
||||||
| `ui` | UI configuration. | `{}` |
|
|
||||||
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
|
||||||
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
|
||||||
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
|
|
||||||
| `ui.logo` | URL to the logo to display. | `""` |
|
|
||||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
|
||||||
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
|
||||||
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
|
||||||
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
|
||||||
| `ui.custom-css` | Custom CSS | `""` |
|
|
||||||
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
|
|
||||||
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
|
|
||||||
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
|
|
||||||
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
||||||
|
|
||||||
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
|
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
|
||||||
@@ -311,7 +300,9 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
|
|||||||
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname from the results. | `false` |
|
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname from the results. | `false` |
|
||||||
| `endpoints[].ui.hide-port` | Whether to hide the port from the results. | `false` |
|
| `endpoints[].ui.hide-port` | Whether to hide the port from the results. | `false` |
|
||||||
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
|
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
|
||||||
|
| `endpoints[].ui.hide-errors` | Whether to hide errors from the results. | `false` |
|
||||||
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
|
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
|
||||||
|
| `endpoints[].ui.resolve-successful-conditions` | Whether to resolve successful conditions for the UI (helpful to expose body assertions even when checks pass). | `false` |
|
||||||
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
|
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
|
||||||
| `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` |
|
| `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` |
|
||||||
| `endpoints[].always-run` | (SUITES ONLY) Whether to execute this endpoint even if previous endpoints in the suite failed. | `false` |
|
| `endpoints[].always-run` | (SUITES ONLY) Whether to execute this endpoint even if previous endpoints in the suite failed. | `false` |
|
||||||
@@ -368,10 +359,10 @@ or send an HTTP request:
|
|||||||
POST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}
|
POST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}
|
||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
|
||||||
- Using the example configuration above, the key would be `core_ext-ep-test`.
|
- Using the example configuration above, the key would be `core_ext-ep-test`.
|
||||||
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
|
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
|
||||||
- `{error}` (optional): a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful.
|
- `{error}` (optional): a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful, this will be ignored.
|
||||||
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
|
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
|
||||||
|
|
||||||
You must also pass the token as a `Bearer` token in the `Authorization` header.
|
You must also pass the token as a `Bearer` token in the `Authorization` header.
|
||||||
@@ -522,16 +513,55 @@ Here are some examples of conditions you can use:
|
|||||||
|
|
||||||
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
|
||||||
|
|
||||||
|
### Web
|
||||||
### Announcements
|
Allows you to configure how and where the dashboard is being served.
|
||||||
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information.
|
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:----------------------------|:----------------------------------------------------------------------------------------------|:---------|
|
|:---------------------------|:--------------------------------------------------------------------------------------------|:----------|
|
||||||
|
| `web` | Web configuration | `{}` |
|
||||||
|
| `web.address` | Address to listen on. | `0.0.0.0` |
|
||||||
|
| `web.port` | Port to listen on. | `8080` |
|
||||||
|
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
|
||||||
|
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
|
||||||
|
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
|
||||||
|
|
||||||
|
### UI
|
||||||
|
Allows you to configure the application wide defaults for the dashboard's UI. Some of these parameters can be overridden locally by users using the local storage of their browser.
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------|
|
||||||
|
| `ui` | UI configuration | `{}` |
|
||||||
|
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
|
||||||
|
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
|
||||||
|
| `ui.dashboard-heading` | Dashboard title between header and endpoints | `Health Dashboard` |
|
||||||
|
| `ui.dashboard-subheading` | Dashboard description between header and endpoints | `Monitor the health of your endpoints in real-time` |
|
||||||
|
| `ui.header` | Header at the top of the dashboard. Also used as the title on the OIDC login page. | `Gatus` |
|
||||||
|
| `ui.logo` | URL to the logo to display. When set, shown alongside the Gatus logo on the OIDC login page. | `""` |
|
||||||
|
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||||
|
| `ui.favicon.default` | Favourite default icon to display in web browser tab or address bar. | `/favicon.ico` |
|
||||||
|
| `ui.favicon.size16x16` | Favourite icon to display in web browser for 16x16 size. | `/favicon-16x16.png` |
|
||||||
|
| `ui.favicon.size32x32` | Favourite icon to display in web browser for 32x32 size. | `/favicon-32x32.png` |
|
||||||
|
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
||||||
|
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
||||||
|
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
||||||
|
| `ui.custom-css` | Custom CSS | `""` |
|
||||||
|
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
|
||||||
|
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
|
||||||
|
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
|
||||||
|
| `ui.login-subtitle` | Subtitle displayed on the OIDC login page. | `System Monitoring Dashboard` |
|
||||||
|
|
||||||
|
### Announcements
|
||||||
|
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information. You can use markdown to format your announcements.
|
||||||
|
|
||||||
|
This is essentially what some status page calls "incident communications".
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|:----------------------------|:-------------------------------------------------------------------------------------------------------------------------|:---------|
|
||||||
| `announcements` | List of announcements to display | `[]` |
|
| `announcements` | List of announcements to display | `[]` |
|
||||||
| `announcements[].timestamp` | UTC timestamp when the announcement was made (RFC3339 format) | Required |
|
| `announcements[].timestamp` | UTC timestamp when the announcement was made (RFC3339 format) | Required |
|
||||||
| `announcements[].type` | Type of announcement. Valid values: `outage`, `warning`, `information`, `operational`, `none` | `"none"` |
|
| `announcements[].type` | Type of announcement. Valid values: `outage`, `warning`, `information`, `operational`, `none` | `"none"` |
|
||||||
| `announcements[].message` | The message to display to users | Required |
|
| `announcements[].message` | The message to display to users | Required |
|
||||||
|
| `announcements[].archived` | Whether to archive the announcement. Archived announcements show at the bottom of the status page instead of at the top. | `false` |
|
||||||
|
|
||||||
Types:
|
Types:
|
||||||
- **outage**: Indicates service disruptions or critical issues (red theme)
|
- **outage**: Indicates service disruptions or critical issues (red theme)
|
||||||
@@ -543,17 +573,24 @@ Types:
|
|||||||
Example Configuration:
|
Example Configuration:
|
||||||
```yaml
|
```yaml
|
||||||
announcements:
|
announcements:
|
||||||
- timestamp: 2025-08-15T14:00:00Z
|
- timestamp: 2025-11-07T14:00:00Z
|
||||||
type: outage
|
type: outage
|
||||||
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
|
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
|
||||||
- timestamp: 2025-08-15T16:15:00Z
|
- timestamp: 2025-11-07T16:15:00Z
|
||||||
type: operational
|
type: operational
|
||||||
message: "Database maintenance completed successfully. All systems operational."
|
message: "Database maintenance completed successfully. All systems operational."
|
||||||
- timestamp: 2025-08-15T12:00:00Z
|
- timestamp: 2025-11-07T12:00:00Z
|
||||||
type: information
|
type: information
|
||||||
message: "New monitoring dashboard features will be deployed next week"
|
message: "New monitoring dashboard features will be deployed next week"
|
||||||
|
- timestamp: 2025-11-06T09:00:00Z
|
||||||
|
type: warning
|
||||||
|
message: "Elevated API response times observed for US customers"
|
||||||
|
archived: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If at least one announcement is archived, a **Past Announcements** section will be rendered at the bottom of the status page:
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
@@ -617,6 +654,7 @@ the client used to send the request.
|
|||||||
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
|
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
|
||||||
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
|
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
|
||||||
| `client.tunnel` | Name of the SSH tunnel to use for this endpoint. See [Tunneling](#tunneling). | `""` |
|
| `client.tunnel` | Name of the SSH tunnel to use for this endpoint. See [Tunneling](#tunneling). | `""` |
|
||||||
|
| `client.store-cookies` | Whether to store cookies between requests. | `false` |
|
||||||
|
|
||||||
|
|
||||||
> 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
|
> 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
|
||||||
@@ -629,6 +667,7 @@ client:
|
|||||||
insecure: false
|
insecure: false
|
||||||
ignore-redirect: false
|
ignore-redirect: false
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
store-cookies: false
|
||||||
```
|
```
|
||||||
|
|
||||||
Note that this configuration is only available under `endpoints[]`, `alerting.mattermost` and `alerting.custom`.
|
Note that this configuration is only available under `endpoints[]`, `alerting.mattermost` and `alerting.custom`.
|
||||||
@@ -745,6 +784,9 @@ endpoints:
|
|||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> ⚠️ **WARNING**:: Tunneling may introduce additional latency, especially if the connection to the tunnel is retried frequently.
|
||||||
|
> This may lead to inaccurate response time measurements.
|
||||||
|
|
||||||
|
|
||||||
### Alerting
|
### Alerting
|
||||||
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
|
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
|
||||||
@@ -796,6 +838,7 @@ endpoints:
|
|||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
|
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
|
||||||
| `alerting.awsses` | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
|
| `alerting.awsses` | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
|
||||||
|
| `alerting.clickup` | Configuration for alerts of type `clickup`. <br />See [Configuring ClickUp alerts](#configuring-clickup-alerts). | `{}` |
|
||||||
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
|
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
|
||||||
| `alerting.datadog` | Configuration for alerts of type `datadog`. <br />See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` |
|
| `alerting.datadog` | Configuration for alerts of type `datadog`. <br />See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` |
|
||||||
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
|
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
|
||||||
@@ -809,11 +852,11 @@ endpoints:
|
|||||||
| `alerting.ifttt` | Configuration for alerts of type `ifttt`. <br />See [Configuring IFTTT alerts](#configuring-ifttt-alerts). | `{}` |
|
| `alerting.ifttt` | Configuration for alerts of type `ifttt`. <br />See [Configuring IFTTT alerts](#configuring-ifttt-alerts). | `{}` |
|
||||||
| `alerting.ilert` | Configuration for alerts of type `ilert`. <br />See [Configuring ilert alerts](#configuring-ilert-alerts). | `{}` |
|
| `alerting.ilert` | Configuration for alerts of type `ilert`. <br />See [Configuring ilert alerts](#configuring-ilert-alerts). | `{}` |
|
||||||
| `alerting.incident-io` | Configuration for alerts of type `incident-io`. <br />See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` |
|
| `alerting.incident-io` | Configuration for alerts of type `incident-io`. <br />See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` |
|
||||||
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
|
|
||||||
| `alerting.line` | Configuration for alerts of type `line`. <br />See [Configuring Line alerts](#configuring-line-alerts). | `{}` |
|
| `alerting.line` | Configuration for alerts of type `line`. <br />See [Configuring Line alerts](#configuring-line-alerts). | `{}` |
|
||||||
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
|
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
|
||||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
|
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
|
||||||
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
|
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
|
||||||
|
| `alerting.n8n` | Configuration for alerts of type `n8n`. <br />See [Configuring n8n alerts](#configuring-n8n-alerts). | `{}` |
|
||||||
| `alerting.newrelic` | Configuration for alerts of type `newrelic`. <br />See [Configuring New Relic alerts](#configuring-new-relic-alerts). | `{}` |
|
| `alerting.newrelic` | Configuration for alerts of type `newrelic`. <br />See [Configuring New Relic alerts](#configuring-new-relic-alerts). | `{}` |
|
||||||
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
|
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
|
||||||
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
|
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
|
||||||
@@ -847,6 +890,9 @@ endpoints:
|
|||||||
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
|
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
|
||||||
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
|
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
|
||||||
| `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
| `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||||
|
| `alerting.aws-ses.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||||
|
| `alerting.aws-ses.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||||
|
| `alerting.aws-ses.overrides[].*` | See `alerting.aws-ses.*` parameters | `{}` |
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
alerting:
|
alerting:
|
||||||
@@ -877,6 +923,72 @@ If the `access-key-id` and `secret-access-key` are not defined Gatus will fall b
|
|||||||
Make sure you have the ability to use `ses:SendEmail`.
|
Make sure you have the ability to use `ses:SendEmail`.
|
||||||
|
|
||||||
|
|
||||||
|
#### Configuring ClickUp alerts
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
| :--------------------------------- | :----------------------------------------------------------------------------------------- | :------------ |
|
||||||
|
| `alerting.clickup` | Configuration for alerts of type `clickup` | `{}` |
|
||||||
|
| `alerting.clickup.list-id` | ClickUp List ID where tasks will be created | Required `""` |
|
||||||
|
| `alerting.clickup.token` | ClickUp API token | Required `""` |
|
||||||
|
| `alerting.clickup.api-url` | Custom API URL | `https://api.clickup.com/api/v2` |
|
||||||
|
| `alerting.clickup.assignees` | List of user IDs to assign tasks to | `[]` |
|
||||||
|
| `alerting.clickup.status` | Initial status for created tasks | `""` |
|
||||||
|
| `alerting.clickup.priority` | Priority level: `urgent`, `high`, `normal`, `low`, or `none` | `normal` |
|
||||||
|
| `alerting.clickup.notify-all` | Whether to notify all assignees when task is created | `true` |
|
||||||
|
| `alerting.clickup.name` | Custom task name template (supports placeholders) | `Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]` |
|
||||||
|
| `alerting.clickup.content` | Custom task content template (supports placeholders) | `Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]` |
|
||||||
|
| `alerting.clickup.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||||
|
| `alerting.clickup.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||||
|
| `alerting.clickup.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||||
|
| `alerting.clickup.overrides[].*` | See `alerting.clickup.*` parameters | `{}` |
|
||||||
|
|
||||||
|
The ClickUp alerting provider creates tasks in a ClickUp list when alerts are triggered. If `send-on-resolved` is set to `true` on the endpoint alert, the task will be automatically closed when the alert is resolved.
|
||||||
|
|
||||||
|
The following placeholders are supported in `name` and `content`:
|
||||||
|
|
||||||
|
- `[ENDPOINT_GROUP]` - Resolved from `endpoints[].group`
|
||||||
|
- `[ENDPOINT_NAME]` - Resolved from `endpoints[].name`
|
||||||
|
- `[ALERT_DESCRIPTION]` - Resolved from `endpoints[].alerts[].description`
|
||||||
|
- `[RESULT_ERRORS]` - Resolved from the health evaluation errors
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alerting:
|
||||||
|
clickup:
|
||||||
|
list-id: "123456789"
|
||||||
|
token: "pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
assignees:
|
||||||
|
- "12345"
|
||||||
|
- "67890"
|
||||||
|
status: "in progress"
|
||||||
|
priority: high
|
||||||
|
name: "Health Check Alert: [ENDPOINT_GROUP] - [ENDPOINT_NAME]"
|
||||||
|
content: "Alert triggered for [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: website
|
||||||
|
url: "https://twin.sh/health"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
alerts:
|
||||||
|
- type: clickup
|
||||||
|
send-on-resolved: true
|
||||||
|
```
|
||||||
|
|
||||||
|
To get your ClickUp API token follow: [Generate or regenerate a Personal API Token](https://developer.clickup.com/docs/authentication#:~:text=the%20API%20docs.-,Generate%20or%20regenerate%20a%20Personal%20API%20Token,-Log%20in%20to)
|
||||||
|
|
||||||
|
To find your List ID:
|
||||||
|
|
||||||
|
1. Open the ClickUp list where you want tasks to be created
|
||||||
|
2. The List ID is in the URL: `https://app.clickup.com/{workspace_id}/v/l/li/{list_id}`
|
||||||
|
|
||||||
|
To find Assignee IDs:
|
||||||
|
|
||||||
|
1. Go to `https://app.clickup.com/{workspace_id}/teams-pulse/teams/people`
|
||||||
|
2. Hover over a team member
|
||||||
|
3. Click the 3 dots (overflow menu)
|
||||||
|
3. Click `Copy member ID`
|
||||||
|
|
||||||
#### Configuring Datadog alerts
|
#### Configuring Datadog alerts
|
||||||
|
|
||||||
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
||||||
@@ -919,6 +1031,7 @@ endpoints:
|
|||||||
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
|
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
|
||||||
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
|
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
|
||||||
| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
|
| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
|
||||||
|
| `alerting.discord.message-content` | Message content to send before the embed (useful for pinging users/roles, e.g. `<@123>`) | `""` |
|
||||||
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||||
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||||
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||||
@@ -1191,6 +1304,10 @@ Here's an example of what the notifications look like:
|
|||||||
|
|
||||||
|
|
||||||
#### Configuring HomeAssistant alerts
|
#### Configuring HomeAssistant alerts
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> It is recommended to use the native Home Assistant integration instead (see below).
|
||||||
|
|
||||||
| Parameter | Description | Default Value |
|
| Parameter | Description | Default Value |
|
||||||
|:-------------------------------------------|:---------------------------------------------------------------------------------------|:--------------|
|
|:-------------------------------------------|:---------------------------------------------------------------------------------------|:--------------|
|
||||||
| `alerting.homeassistant.url` | HomeAssistant instance URL | Required `""` |
|
| `alerting.homeassistant.url` | HomeAssistant instance URL | Required `""` |
|
||||||
@@ -1265,6 +1382,61 @@ To get your HomeAssistant long-lived access token:
|
|||||||
5. Give it a name (e.g., "Gatus")
|
5. Give it a name (e.g., "Gatus")
|
||||||
6. Copy the token - you'll only see it once!
|
6. Copy the token - you'll only see it once!
|
||||||
|
|
||||||
|
##### Native Home Assistant Integration
|
||||||
|
|
||||||
|
Gatus can be integrated into [Home Assistant](https://www.home-assistant.io/) to monitor the status of your endpoints directly from your home automation dashboard.
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=gatus)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Configuration & Automation Examples</summary>
|
||||||
|
|
||||||
|
###### Configuration
|
||||||
|
|
||||||
|
To add the integration, click the button above or:
|
||||||
|
|
||||||
|
1. In Home Assistant, navigate to **Settings** > **Devices & Services**.
|
||||||
|
2. Click **Add Integration** in the bottom-right corner.
|
||||||
|
3. Search for **Gatus** and select it.
|
||||||
|
4. Enter the base URL of your Gatus instance (e.g., `http://192.168.1.50:8080`).
|
||||||
|
|
||||||
|
Home Assistant will create a binary sensor for each endpoint configured in Gatus. Because these sensors use the `connectivity` device class, they will show as `Connected` (Up) when the endpoint is healthy, and `Disconnected` (Down) when the endpoint is unhealthy.
|
||||||
|
|
||||||
|
###### Automation Example
|
||||||
|
|
||||||
|
You can easily set up a UI automation in Home Assistant to receive mobile notifications if any endpoint goes down:
|
||||||
|
|
||||||
|
1. Navigate to **Settings** > **Automations & Scenes** > **Create Automation** > **Create new automation**.
|
||||||
|
2. Under **Triggers**, click **Add Trigger** and select **State**.
|
||||||
|
3. In the **Entity** field, select the Gatus binary sensor(s) you want to monitor.
|
||||||
|
4. Set the **To** field to `off` (since a state of `off` represents a disconnected/down endpoint).
|
||||||
|
5. Under **Actions**, click **Add Action** and select **Perform action**.
|
||||||
|
6. Select your notification service (e.g., `Send a notification via mobile_app_<your_device_name>`).
|
||||||
|
7. Set the **Message** field. You can use template values to dynamically include the endpoint name:
|
||||||
|
```yaml
|
||||||
|
message: "The endpoint {{ trigger.to_state.name }} is down!"
|
||||||
|
```
|
||||||
|
|
||||||
|
For advanced users, here is the YAML representation of the automation:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alias: "Notify when Gatus endpoint goes down"
|
||||||
|
description: "Sends a mobile notification if a Gatus endpoint goes down"
|
||||||
|
trigger:
|
||||||
|
- platform: state
|
||||||
|
entity_id:
|
||||||
|
- binary_sensor.website
|
||||||
|
- binary_sensor.make_sure_header_is_rendered
|
||||||
|
to: "off"
|
||||||
|
action:
|
||||||
|
- action: notify.mobile_app_your_device_name
|
||||||
|
data:
|
||||||
|
title: "Gatus Alert"
|
||||||
|
message: "The endpoint {{ trigger.to_state.name }} is down!"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
#### Configuring IFTTT alerts
|
#### Configuring IFTTT alerts
|
||||||
|
|
||||||
@@ -1298,7 +1470,7 @@ endpoints:
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
#### Configuring ilert alerts
|
#### Configuring Ilert alerts
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|
|
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|
|
||||||
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
|
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
|
||||||
@@ -1380,42 +1552,6 @@ In order to get the required alert source config id and authentication token, yo
|
|||||||
> **_NOTE:_** the source config id is of the form `https://api.incident.io/v2/alert_events/http/$ID` and the token is expected to be passed as a bearer token like so: `Authorization: Bearer $TOKEN`
|
> **_NOTE:_** the source config id is of the form `https://api.incident.io/v2/alert_events/http/$ID` and the token is expected to be passed as a bearer token like so: `Authorization: Bearer $TOKEN`
|
||||||
|
|
||||||
|
|
||||||
#### Configuring JetBrains Space alerts
|
|
||||||
| Parameter | Description | Default |
|
|
||||||
|:--------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
|
||||||
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace` | `{}` |
|
|
||||||
| `alerting.jetbrainsspace.project` | JetBrains Space project name | Required `""` |
|
|
||||||
| `alerting.jetbrainsspace.channel-id` | JetBrains Space Chat Channel ID | Required `""` |
|
|
||||||
| `alerting.jetbrainsspace.token` | Token that is used for authentication. | Required `""` |
|
|
||||||
| `alerting.jetbrainsspace.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
|
||||||
| `alerting.jetbrainsspace.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
|
||||||
| `alerting.jetbrainsspace.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
|
||||||
| `alerting.jetbrainsspace.overrides[].*` | See `alerting.jetbrainsspace.*` parameters | `{}` |
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
alerting:
|
|
||||||
jetbrainsspace:
|
|
||||||
project: myproject
|
|
||||||
channel-id: ABCDE12345
|
|
||||||
token: "**************"
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
- name: website
|
|
||||||
url: "https://twin.sh/health"
|
|
||||||
interval: 5m
|
|
||||||
conditions:
|
|
||||||
- "[STATUS] == 200"
|
|
||||||
alerts:
|
|
||||||
- type: jetbrainsspace
|
|
||||||
description: "healthcheck failed"
|
|
||||||
send-on-resolved: true
|
|
||||||
```
|
|
||||||
|
|
||||||
Here's an example of what the notifications look like:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
#### Configuring Line alerts
|
#### Configuring Line alerts
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
@@ -1576,8 +1712,8 @@ alerting:
|
|||||||
region: "US" # or "EU" for European region
|
region: "US" # or "EU" for European region
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: website
|
- name: example
|
||||||
url: "https://twin.sh/health"
|
url: "https://example.org"
|
||||||
interval: 5m
|
interval: 5m
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
@@ -1587,6 +1723,52 @@ endpoints:
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
#### Configuring n8n alerts
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|:---------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||||
|
| `alerting.n8n` | Configuration for alerts of type `n8n` | `{}` |
|
||||||
|
| `alerting.n8n.webhook-url` | n8n webhook URL | Required `""` |
|
||||||
|
| `alerting.n8n.title` | Title of the alert sent to n8n | `""` |
|
||||||
|
| `alerting.n8n.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||||
|
| `alerting.n8n.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||||
|
| `alerting.n8n.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||||
|
| `alerting.n8n.overrides[].*` | See `alerting.n8n.*` parameters | `{}` |
|
||||||
|
|
||||||
|
[n8n](https://n8n.io/) is a workflow automation platform that allows you to automate tasks across different applications and services using webhooks.
|
||||||
|
|
||||||
|
See [n8n-nodes-gatus-trigger](https://github.com/TwiN/n8n-nodes-gatus-trigger) for a n8n community node that can be used as trigger.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```yaml
|
||||||
|
alerting:
|
||||||
|
n8n:
|
||||||
|
webhook-url: "https://your-n8n-instance.com/webhook/your-webhook-id"
|
||||||
|
title: "Gatus Monitoring"
|
||||||
|
default-alert:
|
||||||
|
send-on-resolved: true
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: example
|
||||||
|
url: "https://example.org"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
alerts:
|
||||||
|
- type: n8n
|
||||||
|
description: "Health check alert"
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON payload sent to the n8n webhook will include:
|
||||||
|
- `title`: The configured title
|
||||||
|
- `endpoint_name`: Name of the endpoint
|
||||||
|
- `endpoint_group`: Group of the endpoint (if any)
|
||||||
|
- `endpoint_url`: URL being monitored
|
||||||
|
- `alert_description`: Custom alert description
|
||||||
|
- `resolved`: Boolean indicating if the alert is resolved
|
||||||
|
- `message`: Human-readable alert message
|
||||||
|
- `condition_results`: Array of condition results with their success status
|
||||||
|
|
||||||
|
|
||||||
#### Configuring Ntfy alerts
|
#### Configuring Ntfy alerts
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
|
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
|
||||||
@@ -1840,6 +2022,40 @@ endpoints:
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
#### Configuring SendGrid alerts
|
||||||
|
|
||||||
|
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||||
|
| `alerting.sendgrid` | Configuration for alerts of type `sendgrid` | `{}` |
|
||||||
|
| `alerting.sendgrid.api-key` | SendGrid API key | Required `""` |
|
||||||
|
| `alerting.sendgrid.from` | Email address to send from | Required `""` |
|
||||||
|
| `alerting.sendgrid.to` | Email address(es) to send alerts to (comma-separated for multiple recipients) | Required `""` |
|
||||||
|
| `alerting.sendgrid.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||||
|
| `alerting.sendgrid.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||||
|
| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||||
|
| `alerting.sendgrid.overrides[].*` | See `alerting.sendgrid.*` parameters | `{}` |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
alerting:
|
||||||
|
sendgrid:
|
||||||
|
api-key: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
from: "alerts@example.com"
|
||||||
|
to: "admin@example.com,ops@example.com"
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: website
|
||||||
|
url: "https://twin.sh/health"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
alerts:
|
||||||
|
- type: sendgrid
|
||||||
|
send-on-resolved: true
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
#### Configuring Signal alerts
|
#### Configuring Signal alerts
|
||||||
|
|
||||||
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
||||||
@@ -1906,40 +2122,6 @@ endpoints:
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
#### Configuring SendGrid alerts
|
|
||||||
|
|
||||||
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
|
||||||
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
|
||||||
| `alerting.sendgrid` | Configuration for alerts of type `sendgrid` | `{}` |
|
|
||||||
| `alerting.sendgrid.api-key` | SendGrid API key | Required `""` |
|
|
||||||
| `alerting.sendgrid.from` | Email address to send from | Required `""` |
|
|
||||||
| `alerting.sendgrid.to` | Email address(es) to send alerts to (comma-separated for multiple recipients) | Required `""` |
|
|
||||||
| `alerting.sendgrid.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
|
||||||
| `alerting.sendgrid.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
|
||||||
| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
|
||||||
| `alerting.sendgrid.overrides[].*` | See `alerting.sendgrid.*` parameters | `{}` |
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
alerting:
|
|
||||||
sendgrid:
|
|
||||||
api-key: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
||||||
from: "alerts@example.com"
|
|
||||||
to: "admin@example.com,ops@example.com"
|
|
||||||
|
|
||||||
endpoints:
|
|
||||||
- name: website
|
|
||||||
url: "https://twin.sh/health"
|
|
||||||
interval: 5m
|
|
||||||
conditions:
|
|
||||||
- "[STATUS] == 200"
|
|
||||||
alerts:
|
|
||||||
- type: sendgrid
|
|
||||||
send-on-resolved: true
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
#### Configuring Slack alerts
|
#### Configuring Slack alerts
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
|
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
|
||||||
@@ -1981,8 +2163,6 @@ Here's an example of what the notifications look like:
|
|||||||
|
|
||||||
#### Configuring Splunk alerts
|
#### Configuring Splunk alerts
|
||||||
|
|
||||||
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------|
|
|:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------|
|
||||||
| `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` |
|
| `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` |
|
||||||
@@ -2166,7 +2346,7 @@ Here's an example of what the notifications look like:
|
|||||||
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
|
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
|
||||||
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
|
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
|
||||||
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
|
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
|
||||||
| `alerting.telegram.id` | Telegram User ID | Required `""` |
|
| `alerting.telegram.id` | Telegram Chat ID | Required `""` |
|
||||||
| `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` |
|
| `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` |
|
||||||
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
|
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
|
||||||
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||||
@@ -2287,7 +2467,7 @@ endpoints:
|
|||||||
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||||
| `alerting.webex` | Configuration for alerts of type `webex` | `{}` |
|
| `alerting.webex` | Configuration for alerts of type `webex` | `{}` |
|
||||||
| `alerting.webex.webhook-url` | Webex Teams webhook URL | Required `""` |
|
| `alerting.webex.webhook-url` | Webex Teams webhook URL | Required `""` |
|
||||||
| `alerting.webex.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
| `alerting.webex.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||||
@@ -2350,6 +2530,7 @@ endpoints:
|
|||||||
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
|
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
|
||||||
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
|
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
|
||||||
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
|
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
|
||||||
|
| `alerting.zulip.topic` | The topic under which the alerts will be sent.<br />Supports the `[ENDPOINT_NAME]`, `[ENDPOINT_GROUP]` and `[ALERT_DESCRIPTION]` placeholders. | `Gatus` |
|
||||||
| `alerting.zulip.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
| `alerting.zulip.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||||
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||||
| `alerting.zulip.overrides[].*` | See `alerting.zulip.*` parameters | `{}` |
|
| `alerting.zulip.overrides[].*` | See `alerting.zulip.*` parameters | `{}` |
|
||||||
@@ -2361,6 +2542,7 @@ alerting:
|
|||||||
bot-api-key: "********************************"
|
bot-api-key: "********************************"
|
||||||
domain: some.zulip.org
|
domain: some.zulip.org
|
||||||
channel-id: 123456
|
channel-id: 123456
|
||||||
|
topic: "[ENDPOINT_NAME]" # Optional; groups each endpoint's alerts under its own topic. Defaults to "Gatus"
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: website
|
- name: website
|
||||||
@@ -2379,7 +2561,7 @@ endpoints:
|
|||||||
|
|
||||||
#### Configuring custom alerts
|
#### Configuring custom alerts
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||||
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
|
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
|
||||||
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
| `alerting.custom.url` | Custom alerting request url | Required `""` |
|
||||||
| `alerting.custom.method` | Request method | `GET` |
|
| `alerting.custom.method` | Request method | `GET` |
|
||||||
@@ -2387,6 +2569,9 @@ endpoints:
|
|||||||
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
|
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
|
||||||
| `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
| `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||||
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||||
|
| `alerting.custom.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||||
|
| `alerting.custom.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||||
|
| `alerting.custom.overrides[].*` | See `alerting.custom.*` parameters | `{}` |
|
||||||
|
|
||||||
While they're called alerts, you can use this feature to call anything.
|
While they're called alerts, you can use this feature to call anything.
|
||||||
|
|
||||||
@@ -2401,7 +2586,8 @@ Furthermore, you may use the following placeholders in the body (`alerting.custo
|
|||||||
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
|
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
|
||||||
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
|
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
|
||||||
- `[RESULT_ERRORS]` (resolved from the health evaluation of a given health check)
|
- `[RESULT_ERRORS]` (resolved from the health evaluation of a given health check)
|
||||||
|
- `[RESULT_CONDITIONS]` (condition results from the health evaluation of a given health check)
|
||||||
|
-
|
||||||
If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
|
If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
|
||||||
`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.
|
`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.
|
||||||
The aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified
|
The aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified
|
||||||
@@ -2662,6 +2848,7 @@ endpoint on the same port your application is configured to run on (`web.port`).
|
|||||||
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
|
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
|
||||||
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
|
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
|
||||||
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
|
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
|
||||||
|
| gatus_results_domain_expiration_seconds | gauge | Number of seconds until the domains expires | key, group, name, type | HTTP, STARTTLS |
|
||||||
| gatus_results_endpoint_success | gauge | Displays whether or not the endpoint was a success (0 failure, 1 success) | key, group, name, type | All |
|
| gatus_results_endpoint_success | gauge | Displays whether or not the endpoint was a success (0 failure, 1 success) | key, group, name, type | All |
|
||||||
|
|
||||||
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
|
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
|
||||||
@@ -2718,11 +2905,12 @@ there are known issues with this feature. If you'd like to provide some feedback
|
|||||||
Use at your own risk.
|
Use at your own risk.
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|:-----------------------------------|:---------------------------------------------|:--------------|
|
|:-----------------------------------|:-----------------------------------------------|:--------------|
|
||||||
| `remote` | Remote configuration | `{}` |
|
| `remote` | Remote configuration | `{}` |
|
||||||
| `remote.instances` | List of remote instances | Required `[]` |
|
| `remote.instances` | List of remote instances | Required `[]` |
|
||||||
| `remote.instances.endpoint-prefix` | String to prefix all endpoint names with | `""` |
|
| `remote.instances.endpoint-prefix` | String to prefix all endpoint names with | `""` |
|
||||||
| `remote.instances.url` | URL from which to retrieve endpoint statuses | Required `""` |
|
| `remote.instances.url` | URL from which to retrieve endpoint statuses | Required `""` |
|
||||||
|
| `remote.client` | [Client configuration](#client-configuration). | `{}` |
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
remote:
|
remote:
|
||||||
@@ -2739,24 +2927,24 @@ Many examples can be found in the [.examples](.examples) folder, but this sectio
|
|||||||
### Docker
|
### Docker
|
||||||
To run Gatus locally with Docker:
|
To run Gatus locally with Docker:
|
||||||
```console
|
```console
|
||||||
docker run -p 8080:8080 --name gatus twinproduction/gatus
|
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
Other than using one of the examples provided in the [.examples](.examples) folder, you can also try it out locally by
|
Other than using one of the examples provided in the [.examples](.examples) folder, you can also try it out locally by
|
||||||
creating a configuration file, we'll call it `config.yaml` for this example, and running the following
|
creating a configuration file, we'll call it `config.yaml` for this example, and running the following
|
||||||
command:
|
command:
|
||||||
```console
|
```console
|
||||||
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
|
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus ghcr.io/twin/gatus:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
If you're on Windows, replace `"$(pwd)"` by the absolute path to your current directory, e.g.:
|
If you're on Windows, replace `"$(pwd)"` by the absolute path to your current directory, e.g.:
|
||||||
```console
|
```console
|
||||||
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
|
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus ghcr.io/twin/gatus:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
To build the image locally:
|
To build the image locally:
|
||||||
```console
|
```console
|
||||||
docker build . -t twinproduction/gatus
|
docker build . -t ghcr.io/twin/gatus:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
@@ -2776,7 +2964,10 @@ To get more details, please check [chart's configuration](https://github.com/Twi
|
|||||||
|
|
||||||
|
|
||||||
### Terraform
|
### Terraform
|
||||||
Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
|
|
||||||
|
#### Kubernetes
|
||||||
|
|
||||||
|
Gatus can be deployed on Kubernetes using Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
|
||||||
|
|
||||||
|
|
||||||
## Running the tests
|
## Running the tests
|
||||||
@@ -2917,20 +3108,59 @@ This works for SCTP based application.
|
|||||||
|
|
||||||
|
|
||||||
### Monitoring a WebSocket endpoint
|
### Monitoring a WebSocket endpoint
|
||||||
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level:
|
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints:
|
||||||
```yaml
|
```yaml
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: example
|
- name: example
|
||||||
url: "wss://example.com/"
|
url: "wss://echo.websocket.org/"
|
||||||
body: "status"
|
body: "status"
|
||||||
conditions:
|
conditions:
|
||||||
- "[CONNECTED] == true"
|
- "[CONNECTED] == true"
|
||||||
- "[BODY].result >= 0"
|
- "[BODY] == pat(*served by*)"
|
||||||
```
|
```
|
||||||
|
|
||||||
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
|
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
|
||||||
shows whether the connection was successfully established. You can use Go template
|
shows whether the connection was successfully established. You can use Go template
|
||||||
syntax. The functions LocalAddr and RandomString with a length can be used.
|
syntax.
|
||||||
|
|
||||||
|
|
||||||
|
### Monitoring an endpoint using gRPC
|
||||||
|
You can monitor gRPC services by prefixing `endpoints[].url` with `grpc://` or `grpcs://`.
|
||||||
|
Gatus executes the standard `grpc.health.v1.Health/Check` RPC against the target.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
endpoints:
|
||||||
|
- name: my-grpc
|
||||||
|
url: grpc://localhost:50051
|
||||||
|
interval: 30s
|
||||||
|
conditions:
|
||||||
|
- "[CONNECTED] == true"
|
||||||
|
- "[BODY].status == SERVING" # BODY is read only when referenced
|
||||||
|
client:
|
||||||
|
timeout: 5s
|
||||||
|
```
|
||||||
|
|
||||||
|
For TLS-enabled servers, use `grpcs://` and configure client TLS if necessary:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
endpoints:
|
||||||
|
- name: my-grpcs
|
||||||
|
url: grpcs://example.com:443
|
||||||
|
conditions:
|
||||||
|
- "[CONNECTED] == true"
|
||||||
|
- "[BODY].status == SERVING"
|
||||||
|
client:
|
||||||
|
timeout: 5s
|
||||||
|
insecure: false # set true to skip cert verification (not recommended)
|
||||||
|
tls:
|
||||||
|
certificate-file: /path/to/cert.pem # optional mTLS client cert
|
||||||
|
private-key-file: /path/to/key.pem # optional mTLS client key
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The health check targets the default service (`service: ""`). Support for a custom service name can be added later if needed.
|
||||||
|
- The response body is exposed as a minimal JSON object like `{"status":"SERVING"}` only when required by conditions or suite store mappings.
|
||||||
|
- Timeouts, custom DNS resolvers and SSH tunnels are honored via the existing [`client` configuration](#client-configuration).
|
||||||
|
|
||||||
|
|
||||||
### Monitoring an endpoint using ICMP
|
### Monitoring an endpoint using ICMP
|
||||||
@@ -2947,9 +3177,12 @@ endpoints:
|
|||||||
Only the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for endpoints of type ICMP.
|
Only the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for endpoints of type ICMP.
|
||||||
You can specify a domain prefixed by `icmp://`, or an IP address prefixed by `icmp://`.
|
You can specify a domain prefixed by `icmp://`, or an IP address prefixed by `icmp://`.
|
||||||
|
|
||||||
If you run Gatus on Linux, please read the Linux section on https://github.com/prometheus-community/pro-bing#linux
|
If you run Gatus on Linux, please read the Linux section on [https://github.com/prometheus-community/pro-bing#linux]
|
||||||
if you encounter any problems.
|
if you encounter any problems.
|
||||||
|
|
||||||
|
Prior to `v5.31.0`, some environment setups required adding `CAP_NET_RAW` capabilities to allow pings to work.
|
||||||
|
As of `v5.31.0`, this is no longer necessary, and ICMP checks will work with unprivileged pings unless running as root. See #1346 for details.
|
||||||
|
|
||||||
|
|
||||||
### Monitoring an endpoint using DNS queries
|
### Monitoring an endpoint using DNS queries
|
||||||
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
|
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
|
||||||
@@ -2975,23 +3208,39 @@ There are two placeholders that can be used in the conditions for endpoints of t
|
|||||||
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
|
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
|
||||||
```yaml
|
```yaml
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: ssh-example
|
# Password-based SSH example
|
||||||
|
- name: ssh-example-password
|
||||||
url: "ssh://example.com:22" # port is optional. Default is 22.
|
url: "ssh://example.com:22" # port is optional. Default is 22.
|
||||||
ssh:
|
ssh:
|
||||||
username: "username"
|
username: "username"
|
||||||
password: "password"
|
password: "password"
|
||||||
body: |
|
body: |
|
||||||
{
|
{
|
||||||
"command": "uptime"
|
"command": "echo '{\"memory\": {\"used\": 512}}'"
|
||||||
}
|
}
|
||||||
interval: 1m
|
interval: 1m
|
||||||
conditions:
|
conditions:
|
||||||
- "[CONNECTED] == true"
|
- "[CONNECTED] == true"
|
||||||
- "[STATUS] == 0"
|
- "[STATUS] == 0"
|
||||||
|
- "[BODY].memory.used > 500"
|
||||||
|
|
||||||
|
# Key-based SSH example
|
||||||
|
- name: ssh-example-key
|
||||||
|
url: "ssh://example.com:22" # port is optional. Default is 22.
|
||||||
|
ssh:
|
||||||
|
username: "username"
|
||||||
|
private-key: |
|
||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
TESTRSAKEY...
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
interval: 1m
|
||||||
|
conditions:
|
||||||
|
- "[CONNECTED] == true"
|
||||||
|
- "[STATUS] == 0"
|
||||||
```
|
```
|
||||||
|
|
||||||
you can also use no authentication to monitor the endpoint by not specifying the username
|
you can also use no authentication to monitor the endpoint by not specifying the username,
|
||||||
and password fields.
|
password and private key fields.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
endpoints:
|
endpoints:
|
||||||
@@ -3000,6 +3249,7 @@ endpoints:
|
|||||||
ssh:
|
ssh:
|
||||||
username: ""
|
username: ""
|
||||||
password: ""
|
password: ""
|
||||||
|
private-key: ""
|
||||||
|
|
||||||
interval: 1m
|
interval: 1m
|
||||||
conditions:
|
conditions:
|
||||||
@@ -3010,6 +3260,7 @@ endpoints:
|
|||||||
The following placeholders are supported for endpoints of type SSH:
|
The following placeholders are supported for endpoints of type SSH:
|
||||||
- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
|
- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
|
||||||
- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
|
- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
|
||||||
|
- `[BODY]` resolves to the stdout output of the command executed on the remote server
|
||||||
- `[IP]` resolves to the IP address of the server
|
- `[IP]` resolves to the IP address of the server
|
||||||
- `[RESPONSE_TIME]` resolves to the time it took to establish the connection and execute the command
|
- `[RESPONSE_TIME]` resolves to the time it took to establish the connection and execute the command
|
||||||
|
|
||||||
@@ -3070,7 +3321,7 @@ endpoints:
|
|||||||
|
|
||||||
|
|
||||||
### Concurrency
|
### Concurrency
|
||||||
By default, Gatus allows up to 5 endpoints/suites to be monitored concurrently. This provides a balance between performance and resource usage while maintaining accurate response time measurements.
|
By default, Gatus allows up to 3 endpoints/suites to be monitored concurrently. This provides a balance between performance and resource usage while maintaining accurate response time measurements.
|
||||||
|
|
||||||
You can configure the concurrency level using the `concurrency` parameter:
|
You can configure the concurrency level using the `concurrency` parameter:
|
||||||
|
|
||||||
@@ -3193,12 +3444,19 @@ web:
|
|||||||
```
|
```
|
||||||
|
|
||||||
If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment
|
If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment
|
||||||
variable instead, you can use that environment variable directly in the configuration file:
|
variable instead see [Use environment variables in config files](#use-environment-variables-in-config-files).
|
||||||
|
|
||||||
|
### Use environment variables in config files
|
||||||
|
|
||||||
|
You can use environment variables directly in the configuration file which will be substituted from the environment:
|
||||||
```yaml
|
```yaml
|
||||||
web:
|
web:
|
||||||
port: ${PORT}
|
port: ${PORT}
|
||||||
```
|
|
||||||
|
|
||||||
|
ui:
|
||||||
|
title: $TITLE
|
||||||
|
```
|
||||||
|
⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
|
||||||
|
|
||||||
### Configuring a startup delay
|
### Configuring a startup delay
|
||||||
If, for any reason, you need Gatus to wait for a given amount of time before monitoring the endpoints on application start, you can use the `GATUS_DELAY_START_SECONDS` environment variable to make Gatus sleep on startup.
|
If, for any reason, you need Gatus to wait for a given amount of time before monitoring the endpoints on application start, you can use the `GATUS_DELAY_START_SECONDS` environment variable to make Gatus sleep on startup.
|
||||||
@@ -3282,7 +3540,7 @@ The path to generate a badge is the following:
|
|||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
|
||||||
|
|
||||||
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
|
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
|
||||||
the URL would look like this:
|
the URL would look like this:
|
||||||
@@ -3308,7 +3566,7 @@ The path to generate a badge is the following:
|
|||||||
/api/v1/endpoints/{key}/health/badge.svg
|
/api/v1/endpoints/{key}/health/badge.svg
|
||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
|
||||||
|
|
||||||
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
|
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
|
||||||
the URL would look like this:
|
the URL would look like this:
|
||||||
@@ -3325,7 +3583,7 @@ The path to generate a badge is the following:
|
|||||||
/api/v1/endpoints/{key}/health/badge.shields
|
/api/v1/endpoints/{key}/health/badge.shields
|
||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
|
||||||
|
|
||||||
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
|
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
|
||||||
the URL would look like this:
|
the URL would look like this:
|
||||||
@@ -3348,7 +3606,7 @@ The endpoint to generate a badge is the following:
|
|||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
|
||||||
|
|
||||||
#### Response time (chart)
|
#### Response time (chart)
|
||||||

|

|
||||||
@@ -3361,7 +3619,7 @@ The endpoint to generate a response time chart is the following:
|
|||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{duration}` is `30d`, `7d`, or `24h`
|
- `{duration}` is `30d`, `7d`, or `24h`
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
|
||||||
|
|
||||||
##### How to change the color thresholds of the response time badge
|
##### How to change the color thresholds of the response time badge
|
||||||
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
|
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
|
||||||
@@ -3419,7 +3677,7 @@ The path to get raw uptime data for an endpoint is:
|
|||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
|
||||||
|
|
||||||
For instance, if you want the raw uptime data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
|
For instance, if you want the raw uptime data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
|
||||||
```
|
```
|
||||||
@@ -3433,7 +3691,7 @@ The path to get raw response time data for an endpoint is:
|
|||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
|
||||||
|
|
||||||
For instance, if you want the raw response time data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
|
For instance, if you want the raw response time data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const (
|
|||||||
// TypeAWSSES is the Type for the awsses alerting provider
|
// TypeAWSSES is the Type for the awsses alerting provider
|
||||||
TypeAWSSES Type = "aws-ses"
|
TypeAWSSES Type = "aws-ses"
|
||||||
|
|
||||||
|
// TypeClickUp is the Type for the clickup alerting provider
|
||||||
|
TypeClickUp Type = "clickup"
|
||||||
|
|
||||||
// TypeCustom is the Type for the custom alerting provider
|
// TypeCustom is the Type for the custom alerting provider
|
||||||
TypeCustom Type = "custom"
|
TypeCustom Type = "custom"
|
||||||
|
|
||||||
@@ -47,9 +50,6 @@ const (
|
|||||||
// TypeIncidentIO is the Type for the incident-io alerting provider
|
// TypeIncidentIO is the Type for the incident-io alerting provider
|
||||||
TypeIncidentIO Type = "incident-io"
|
TypeIncidentIO Type = "incident-io"
|
||||||
|
|
||||||
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
|
||||||
TypeJetBrainsSpace Type = "jetbrainsspace"
|
|
||||||
|
|
||||||
// TypeLine is the Type for the line alerting provider
|
// TypeLine is the Type for the line alerting provider
|
||||||
TypeLine Type = "line"
|
TypeLine Type = "line"
|
||||||
|
|
||||||
@@ -65,6 +65,9 @@ const (
|
|||||||
// TypeNewRelic is the Type for the newrelic alerting provider
|
// TypeNewRelic is the Type for the newrelic alerting provider
|
||||||
TypeNewRelic Type = "newrelic"
|
TypeNewRelic Type = "newrelic"
|
||||||
|
|
||||||
|
// TypeN8N is the Type for the n8n alerting provider
|
||||||
|
TypeN8N Type = "n8n"
|
||||||
|
|
||||||
// TypeNtfy is the Type for the ntfy alerting provider
|
// TypeNtfy is the Type for the ntfy alerting provider
|
||||||
TypeNtfy Type = "ntfy"
|
TypeNtfy Type = "ntfy"
|
||||||
|
|
||||||
|
|||||||
+8
-5
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
@@ -20,11 +21,11 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||||
@@ -54,6 +55,9 @@ type Config struct {
|
|||||||
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
|
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
|
||||||
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
|
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
|
||||||
|
|
||||||
|
// ClickUp is the configuration for the clickup alerting provider
|
||||||
|
ClickUp *clickup.AlertProvider `yaml:"clickup,omitempty"`
|
||||||
|
|
||||||
// Custom is the configuration for the custom alerting provider
|
// Custom is the configuration for the custom alerting provider
|
||||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||||
|
|
||||||
@@ -66,7 +70,6 @@ type Config struct {
|
|||||||
// Email is the configuration for the email alerting provider
|
// Email is the configuration for the email alerting provider
|
||||||
Email *email.AlertProvider `yaml:"email,omitempty"`
|
Email *email.AlertProvider `yaml:"email,omitempty"`
|
||||||
|
|
||||||
|
|
||||||
// GitHub is the configuration for the github alerting provider
|
// GitHub is the configuration for the github alerting provider
|
||||||
GitHub *github.AlertProvider `yaml:"github,omitempty"`
|
GitHub *github.AlertProvider `yaml:"github,omitempty"`
|
||||||
|
|
||||||
@@ -94,9 +97,6 @@ type Config struct {
|
|||||||
// IncidentIO is the configuration for the incident-io alerting provider
|
// IncidentIO is the configuration for the incident-io alerting provider
|
||||||
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
|
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
|
||||||
|
|
||||||
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
|
||||||
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
|
|
||||||
|
|
||||||
// Line is the configuration for the line alerting provider
|
// Line is the configuration for the line alerting provider
|
||||||
Line *line.AlertProvider `yaml:"line,omitempty"`
|
Line *line.AlertProvider `yaml:"line,omitempty"`
|
||||||
|
|
||||||
@@ -112,6 +112,9 @@ type Config struct {
|
|||||||
// NewRelic is the configuration for the newrelic alerting provider
|
// NewRelic is the configuration for the newrelic alerting provider
|
||||||
NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
|
NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
|
||||||
|
|
||||||
|
// N8N is the configuration for the n8n alerting provider
|
||||||
|
N8N *n8n.AlertProvider `yaml:"n8n,omitempty"`
|
||||||
|
|
||||||
// Ntfy is the configuration for the ntfy alerting provider
|
// Ntfy is the configuration for the ntfy alerting provider
|
||||||
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
package awsses
|
package awsses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/logr"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
"github.com/aws/aws-sdk-go-v2/service/ses"
|
||||||
"github.com/aws/aws-sdk-go/aws/session"
|
"github.com/aws/aws-sdk-go-v2/service/ses/types"
|
||||||
"github.com/aws/aws-sdk-go/service/ses"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,63 +102,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
awsSession, err := provider.createSession(cfg)
|
ctx := context.Background()
|
||||||
|
svc, err := provider.createClient(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
svc := ses.New(awsSession)
|
|
||||||
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||||
emails := strings.Split(cfg.To, ",")
|
emails := strings.Split(cfg.To, ",")
|
||||||
|
|
||||||
input := &ses.SendEmailInput{
|
input := &ses.SendEmailInput{
|
||||||
Destination: &ses.Destination{
|
Destination: &types.Destination{
|
||||||
ToAddresses: aws.StringSlice(emails),
|
ToAddresses: emails,
|
||||||
},
|
},
|
||||||
Message: &ses.Message{
|
Message: &types.Message{
|
||||||
Body: &ses.Body{
|
Body: &types.Body{
|
||||||
Text: &ses.Content{
|
Text: &types.Content{
|
||||||
Charset: aws.String(CharSet),
|
Charset: aws.String(CharSet),
|
||||||
Data: aws.String(body),
|
Data: aws.String(body),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Subject: &ses.Content{
|
Subject: &types.Content{
|
||||||
Charset: aws.String(CharSet),
|
Charset: aws.String(CharSet),
|
||||||
Data: aws.String(subject),
|
Data: aws.String(subject),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Source: aws.String(cfg.From),
|
Source: aws.String(cfg.From),
|
||||||
}
|
}
|
||||||
if _, err = svc.SendEmail(input); err != nil {
|
if _, err = svc.SendEmail(ctx, input); err != nil {
|
||||||
if aerr, ok := err.(awserr.Error); ok {
|
|
||||||
switch aerr.Code() {
|
|
||||||
case ses.ErrCodeMessageRejected:
|
|
||||||
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
|
|
||||||
case ses.ErrCodeMailFromDomainNotVerifiedException:
|
|
||||||
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
|
|
||||||
case ses.ErrCodeConfigurationSetDoesNotExistException:
|
|
||||||
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
|
|
||||||
default:
|
|
||||||
logr.Error(aerr.Error())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Print the error, cast err to awserr.Error to get the Code and
|
|
||||||
// Message from an error.
|
|
||||||
logr.Error(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
|
func (provider *AlertProvider) createClient(ctx context.Context, cfg *Config) (*ses.Client, error) {
|
||||||
awsConfig := &aws.Config{
|
var opts []func(*config.LoadOptions) error
|
||||||
Region: aws.String(cfg.Region),
|
if len(cfg.Region) > 0 {
|
||||||
|
opts = append(opts, config.WithRegion(cfg.Region))
|
||||||
}
|
}
|
||||||
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
|
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
|
||||||
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
|
opts = append(opts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")))
|
||||||
}
|
}
|
||||||
return session.NewSession(awsConfig)
|
awsConfig, err := config.LoadDefaultConfig(ctx, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ses.NewFromConfig(awsConfig), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMessageSubjectAndBody builds the message subject and body
|
// buildMessageSubjectAndBody builds the message subject and body
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package clickup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrListIDNotSet = errors.New("list-id not set")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
ErrInvalidPriority = errors.New("priority must be one of: urgent, high, normal, low, none")
|
||||||
|
)
|
||||||
|
|
||||||
|
var priorityMap = map[string]int{
|
||||||
|
"urgent": 1,
|
||||||
|
"high": 2,
|
||||||
|
"normal": 3,
|
||||||
|
"low": 4,
|
||||||
|
"none": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIURL string `yaml:"api-url"`
|
||||||
|
ListID string `yaml:"list-id"`
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
Assignees []string `yaml:"assignees"`
|
||||||
|
Status string `yaml:"status"`
|
||||||
|
Priority string `yaml:"priority"`
|
||||||
|
NotifyAll *bool `yaml:"notify-all,omitempty"`
|
||||||
|
Name string `yaml:"name,omitempty"`
|
||||||
|
MarkdownContent string `yaml:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if cfg.ListID == "" {
|
||||||
|
return ErrListIDNotSet
|
||||||
|
}
|
||||||
|
if cfg.Token == "" {
|
||||||
|
return ErrTokenNotSet
|
||||||
|
}
|
||||||
|
if cfg.Priority == "" {
|
||||||
|
cfg.Priority = "normal"
|
||||||
|
}
|
||||||
|
if _, ok := priorityMap[cfg.Priority]; !ok {
|
||||||
|
return ErrInvalidPriority
|
||||||
|
}
|
||||||
|
if cfg.NotifyAll == nil {
|
||||||
|
defaultNotifyAll := true
|
||||||
|
cfg.NotifyAll = &defaultNotifyAll
|
||||||
|
}
|
||||||
|
if cfg.APIURL == "" {
|
||||||
|
cfg.APIURL = "https://api.clickup.com/api/v2"
|
||||||
|
}
|
||||||
|
if cfg.Name == "" {
|
||||||
|
cfg.Name = "Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]"
|
||||||
|
}
|
||||||
|
if cfg.MarkdownContent == "" {
|
||||||
|
cfg.MarkdownContent = "Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.APIURL != "" {
|
||||||
|
cfg.APIURL = override.APIURL
|
||||||
|
}
|
||||||
|
if override.ListID != "" {
|
||||||
|
cfg.ListID = override.ListID
|
||||||
|
}
|
||||||
|
if override.Token != "" {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
if override.Status != "" {
|
||||||
|
cfg.Status = override.Status
|
||||||
|
}
|
||||||
|
if override.Priority != "" {
|
||||||
|
cfg.Priority = override.Priority
|
||||||
|
}
|
||||||
|
if override.NotifyAll != nil {
|
||||||
|
cfg.NotifyAll = override.NotifyAll
|
||||||
|
}
|
||||||
|
if len(override.Assignees) > 0 {
|
||||||
|
cfg.Assignees = override.Assignees
|
||||||
|
}
|
||||||
|
if override.Name != "" {
|
||||||
|
cfg.Name = override.Name
|
||||||
|
}
|
||||||
|
if override.MarkdownContent != "" {
|
||||||
|
cfg.MarkdownContent = override.MarkdownContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using ClickUp
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default configuration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
return provider.CloseTask(cfg, ep)
|
||||||
|
}
|
||||||
|
// Replace placeholders
|
||||||
|
name := strings.ReplaceAll(cfg.Name, "[ENDPOINT_GROUP]", ep.Group)
|
||||||
|
name = strings.ReplaceAll(name, "[ENDPOINT_NAME]", ep.Name)
|
||||||
|
markdownContent := strings.ReplaceAll(cfg.MarkdownContent, "[ENDPOINT_GROUP]", ep.Group)
|
||||||
|
markdownContent = strings.ReplaceAll(markdownContent, "[ENDPOINT_NAME]", ep.Name)
|
||||||
|
markdownContent = strings.ReplaceAll(markdownContent, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||||
|
markdownContent = strings.ReplaceAll(markdownContent, "[RESULT_ERRORS]", strings.Join(result.Errors, ", "))
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"markdown_content": markdownContent,
|
||||||
|
"assignees": cfg.Assignees,
|
||||||
|
"status": cfg.Status,
|
||||||
|
"notify_all": *cfg.NotifyAll,
|
||||||
|
}
|
||||||
|
if cfg.Priority != "none" {
|
||||||
|
body["priority"] = priorityMap[cfg.Priority]
|
||||||
|
}
|
||||||
|
return provider.CreateTask(cfg, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error {
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
createURL := fmt.Sprintf("%s/list/%s/task", cfg.APIURL, cfg.ListID)
|
||||||
|
req, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", cfg.Token)
|
||||||
|
httpClient := client.GetHTTPClient(nil)
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("failed to create task, status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error {
|
||||||
|
fetchURL := fmt.Sprintf("%s/list/%s/task?include_closed=false", cfg.APIURL, cfg.ListID)
|
||||||
|
req, err := http.NewRequest("GET", fetchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", cfg.Token)
|
||||||
|
httpClient := client.GetHTTPClient(nil)
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("failed to fetch tasks, status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var fetchResponse struct {
|
||||||
|
Tasks []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"tasks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var matchingTaskIDs []string
|
||||||
|
for _, task := range fetchResponse.Tasks {
|
||||||
|
if strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) {
|
||||||
|
matchingTaskIDs = append(matchingTaskIDs, task.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matchingTaskIDs) == 0 {
|
||||||
|
return fmt.Errorf("no matching tasks found for %s:%s", ep.Group, ep.Name)
|
||||||
|
}
|
||||||
|
for _, taskID := range matchingTaskIDs {
|
||||||
|
if err := provider.UpdateTaskStatus(cfg, taskID, "closed"); err != nil {
|
||||||
|
return fmt.Errorf("failed to close task %s: %v", taskID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error {
|
||||||
|
updateURL := fmt.Sprintf("%s/task/%s", cfg.APIURL, taskID)
|
||||||
|
body := map[string]interface{}{"status": status}
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("PUT", updateURL, bytes.NewBuffer(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", cfg.Token)
|
||||||
|
httpClient := client.GetHTTPClient(nil)
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("failed to update task %s, status: %d", taskID, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package clickup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
invalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: "", Token: "test-token"}}
|
||||||
|
if err := invalidProviderNoListID.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid without list-id")
|
||||||
|
}
|
||||||
|
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: ""}}
|
||||||
|
if err := invalidProviderNoToken.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid without token")
|
||||||
|
}
|
||||||
|
invalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "invalid"}}
|
||||||
|
if err := invalidProviderBadPriority.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid with invalid priority")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
if validProvider.DefaultConfig.Priority != "normal" {
|
||||||
|
t.Errorf("expected default priority to be 'normal', got '%s'", validProvider.DefaultConfig.Priority)
|
||||||
|
}
|
||||||
|
validProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", APIURL: "https://api.clickup.com/api/v2"}}
|
||||||
|
if err := validProviderWithAPIURL.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
validProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}}
|
||||||
|
if err := validProviderWithPriority.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid with priority 'urgent'")
|
||||||
|
}
|
||||||
|
validProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}}
|
||||||
|
if err := validProviderWithNone.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid with priority 'none'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) {
|
||||||
|
provider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
|
||||||
|
if err := provider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
if provider.DefaultConfig.APIURL != "https://api.clickup.com/api/v2" {
|
||||||
|
t.Errorf("expected APIURL to be set to default, got %s", provider.DefaultConfig.APIURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.Method == "POST" && r.URL.Path == "/api/v2/list/test-list-id/task" {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
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{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
// Mock fetch tasks response
|
||||||
|
tasksResponse := map[string]interface{}{
|
||||||
|
"tasks": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": "task-123",
|
||||||
|
"name": "Health Check: endpoint-group:endpoint-name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(tasksResponse)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Method == "PUT" {
|
||||||
|
// Mock update task status response
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-no-matching-tasks",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
// Mock fetch tasks response with no matching tasks
|
||||||
|
tasksResponse := map[string]interface{}{
|
||||||
|
"tasks": []map[string]interface{}{},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(tasksResponse)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error-fetching-tasks",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
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(
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
Errors: []string{"error1", "error2"},
|
||||||
|
},
|
||||||
|
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_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "normal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||||
|
"list-id": "override-list-id",
|
||||||
|
"token": "override-token",
|
||||||
|
}},
|
||||||
|
ExpectedOutput: Config{ListID: "override-list-id", Token: "override-token", Priority: "normal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-partial-alert-override-should-merge",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Status: "in progress"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||||
|
"status": "closed",
|
||||||
|
}},
|
||||||
|
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Status: "closed", Priority: "normal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-assignees-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||||
|
"assignees": []string{"user1", "user2"},
|
||||||
|
}},
|
||||||
|
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Assignees: []string{"user1", "user2"}, Priority: "normal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-priority-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||||
|
"priority": "urgent",
|
||||||
|
}},
|
||||||
|
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-none-priority",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||||
|
"priority": "none",
|
||||||
|
}},
|
||||||
|
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{Group: "core", Config: Config{ListID: "core-list-id", Priority: "urgent"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "core",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ListID: "core-list-id", Token: "test-token", Priority: "urgent"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.ListID != scenario.ExpectedOutput.ListID {
|
||||||
|
t.Errorf("expected ListID to be %s, got %s", scenario.ExpectedOutput.ListID, got.ListID)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected Token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
if got.Status != scenario.ExpectedOutput.Status {
|
||||||
|
t.Errorf("expected Status to be %s, got %s", scenario.ExpectedOutput.Status, got.Status)
|
||||||
|
}
|
||||||
|
if got.Priority != scenario.ExpectedOutput.Priority {
|
||||||
|
t.Errorf("expected Priority to be %s, got %s", scenario.ExpectedOutput.Priority, got.Priority)
|
||||||
|
}
|
||||||
|
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
|
||||||
|
t.Errorf("expected Assignees length to be %d, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,25 @@ func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoi
|
|||||||
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
|
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
|
||||||
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
|
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
|
||||||
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
|
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
|
||||||
|
|
||||||
|
if len(result.ConditionResults) > 0 && strings.Contains(body, "[RESULT_CONDITIONS]") {
|
||||||
|
var formattedConditionResults string
|
||||||
|
for index, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s - `%s`", prefix, conditionResult.Condition)
|
||||||
|
if index < len(result.ConditionResults)-1 {
|
||||||
|
formattedConditionResults += ", "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body = strings.ReplaceAll(body, "[RESULT_CONDITIONS]", formattedConditionResults)
|
||||||
|
url = strings.ReplaceAll(url, "[RESULT_CONDITIONS]", formattedConditionResults)
|
||||||
|
}
|
||||||
|
|
||||||
if resolved {
|
if resolved {
|
||||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||||
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||||
|
|||||||
@@ -261,6 +261,69 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholderAndResultConditions(t *testing.T) {
|
||||||
|
alertProvider := &AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||||
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_CONDITIONS]",
|
||||||
|
Headers: nil,
|
||||||
|
Placeholders: map[string]map[string]string{
|
||||||
|
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||||
|
"RESOLVED": "fixed",
|
||||||
|
"TRIGGERED": "boom",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
alertDescription := "alert-description"
|
||||||
|
scenarios := []struct {
|
||||||
|
AlertProvider *AlertProvider
|
||||||
|
Resolved bool
|
||||||
|
ExpectedURL string
|
||||||
|
ExpectedBody string
|
||||||
|
NoConditions bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed,✅ - `[CONNECTED] == true`, ✅ - `[STATUS] == 200`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom,❌ - `[CONNECTED] == true`, ❌ - `[STATUS] == 200`",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||||
|
var conditionResults []*endpoint.ConditionResult
|
||||||
|
if !scenario.NoConditions {
|
||||||
|
conditionResults = []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request := alertProvider.buildHTTPRequest(
|
||||||
|
&alertProvider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
&endpoint.Result{ConditionResults: conditionResults},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if request.URL.String() != scenario.ExpectedURL {
|
||||||
|
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(request.Body)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||||
alertProvider := &AlertProvider{
|
alertProvider := &AlertProvider{
|
||||||
DefaultConfig: Config{
|
DefaultConfig: Config{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ var (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
|
MessageContent string `yaml:"message-content,omitempty"` // Message content for pinging users or groups (e.g. "<@123456789>" or "<@&987654321>")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) Validate() error {
|
func (cfg *Config) Validate() error {
|
||||||
@@ -38,6 +39,9 @@ func (cfg *Config) Merge(override *Config) {
|
|||||||
if len(override.Title) > 0 {
|
if len(override.Title) > 0 {
|
||||||
cfg.Title = override.Title
|
cfg.Title = override.Title
|
||||||
}
|
}
|
||||||
|
if len(override.MessageContent) > 0 {
|
||||||
|
cfg.MessageContent = override.MessageContent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
@@ -142,7 +146,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
|||||||
title = cfg.Title
|
title = cfg.Title
|
||||||
}
|
}
|
||||||
body := Body{
|
body := Body{
|
||||||
Content: "",
|
Content: cfg.MessageContent,
|
||||||
Embeds: []Embed{
|
Embeds: []Embed{
|
||||||
{
|
{
|
||||||
Title: title,
|
Title: title,
|
||||||
|
|||||||
@@ -134,6 +134,16 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-message-content",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
@@ -200,6 +210,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-message-content-user-mention",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-message-content-role-mention",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@&987654321>"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"content\":\"\\u003c@\\u0026987654321\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-message-content",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
@@ -313,6 +344,39 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
|||||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-message-content-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-message-content-group-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-message-content-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"message-content": "<@999999999>"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@999999999>"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
@@ -323,6 +387,9 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
|||||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
}
|
}
|
||||||
|
if got.MessageContent != scenario.ExpectedOutput.MessageContent {
|
||||||
|
t.Errorf("expected message content to be %s, got %s", scenario.ExpectedOutput.MessageContent, got.MessageContent)
|
||||||
|
}
|
||||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
t.Errorf("unexpected error: %s", err)
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
|||||||
@@ -143,10 +143,10 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
||||||
var subject, message string
|
var subject, message string
|
||||||
if resolved {
|
if resolved {
|
||||||
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
|
subject = fmt.Sprintf("%s: Alert resolved", ep.DisplayName())
|
||||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
|
subject = fmt.Sprintf("%s: Alert triggered", ep.DisplayName())
|
||||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
var formattedConditionResults string
|
var formattedConditionResults string
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
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",
|
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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,7 +95,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
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",
|
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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,7 +104,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
|
||||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
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\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
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\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -113,7 +113,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
|
||||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
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\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -122,7 +122,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
|
||||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
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",
|
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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isIgnorableTestError checks if an error is expected during testing when making API calls with dummy credentials
|
||||||
|
func isIgnorableTestError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
errStr := err.Error()
|
||||||
|
return strings.Contains(errStr, "user does not exist") ||
|
||||||
|
strings.Contains(errStr, "no such host") ||
|
||||||
|
strings.Contains(errStr, "invalid username, password or token") ||
|
||||||
|
strings.Contains(errStr, "unknown API error") ||
|
||||||
|
strings.Contains(errStr, "dial tcp")
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlertProvider_Validate(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -50,7 +63,7 @@ func TestAlertProvider_Validate(t *testing.T) {
|
|||||||
if scenario.ExpectedError && err == nil {
|
if scenario.ExpectedError && err == nil {
|
||||||
t.Error("expected error, got none")
|
t.Error("expected error, got none")
|
||||||
}
|
}
|
||||||
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
if !scenario.ExpectedError && err != nil && !isIgnorableTestError(err) {
|
||||||
t.Error("expected no error, got", err.Error())
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -87,7 +100,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
|
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
|
||||||
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
if err != nil && !isIgnorableTestError(err) {
|
||||||
t.Error("expected no error, got", err.Error())
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
|
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
|
||||||
@@ -203,7 +216,7 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
if err != nil && !isIgnorableTestError(err) {
|
||||||
t.Fatalf("unexpected error: %s", err)
|
t.Fatalf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
|
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
|
||||||
@@ -221,7 +234,7 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "user does not exist") {
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !isIgnorableTestError(err) {
|
||||||
t.Errorf("unexpected error: %s", err)
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package incidentio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateDeduplicationKey generates a unique deduplication_key for incident.io
|
||||||
|
func generateDeduplicationKey(ep *endpoint.Endpoint, alert *alert.Alert) string {
|
||||||
|
data := fmt.Sprintf("%s|%s|%s|%d", ep.Key(), alert.Type, alert.GetDescription(), time.Now().UnixNano())
|
||||||
|
hash := sha256.Sum256([]byte(data))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -153,27 +154,42 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
|||||||
} else {
|
} else {
|
||||||
prefix = "🔴"
|
prefix = "🔴"
|
||||||
}
|
}
|
||||||
// No need for \n since incident.io trims it anyways.
|
|
||||||
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
if len(alert.GetDescription()) > 0 {
|
if len(alert.GetDescription()) > 0 {
|
||||||
message += " with the following description: " + alert.GetDescription()
|
message += " with the following description: " + alert.GetDescription()
|
||||||
}
|
}
|
||||||
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
|
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
|
||||||
var body []byte
|
|
||||||
|
// Generate deduplication key if empty (first firing)
|
||||||
|
if alert.ResolveKey == "" {
|
||||||
|
// Generate unique key (endpoint key, alert type, timestamp)
|
||||||
|
alert.ResolveKey = generateDeduplicationKey(ep, alert)
|
||||||
|
}
|
||||||
|
// Extract alert_source_config_id from URL
|
||||||
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
|
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
|
||||||
body, _ = json.Marshal(Body{
|
// Merge metadata: cfg.Metadata + ep.ExtraLabels (if present)
|
||||||
|
mergedMetadata := map[string]interface{}{}
|
||||||
|
// Copy cfg.Metadata
|
||||||
|
maps.Copy(mergedMetadata, cfg.Metadata)
|
||||||
|
// Add extra labels from endpoint (if present)
|
||||||
|
if ep.ExtraLabels != nil && len(ep.ExtraLabels) > 0 {
|
||||||
|
for k, v := range ep.ExtraLabels {
|
||||||
|
mergedMetadata[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(Body{
|
||||||
AlertSourceConfigID: alertSourceID,
|
AlertSourceConfigID: alertSourceID,
|
||||||
Title: "Gatus: " + ep.DisplayName(),
|
Title: "Gatus: " + ep.DisplayName(),
|
||||||
Status: status,
|
Status: status,
|
||||||
DeduplicationKey: alert.ResolveKey,
|
DeduplicationKey: alert.ResolveKey,
|
||||||
Description: message,
|
Description: message,
|
||||||
SourceURL: cfg.SourceURL,
|
SourceURL: cfg.SourceURL,
|
||||||
Metadata: cfg.Metadata,
|
Metadata: mergedMetadata,
|
||||||
})
|
})
|
||||||
fmt.Printf("%v", string(body))
|
fmt.Printf("%v", string(body))
|
||||||
return body
|
return body
|
||||||
|
|
||||||
}
|
}
|
||||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
cfg := provider.DefaultConfig
|
cfg := provider.DefaultConfig
|
||||||
|
|||||||
@@ -187,35 +187,59 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
|||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedAlertSourceID string
|
||||||
|
ExpectedStatus string
|
||||||
|
ExpectedTitle string
|
||||||
|
ExpectedDescription string
|
||||||
|
ExpectedSourceURL string
|
||||||
|
ExpectedMetadata map[string]interface{}
|
||||||
|
ShouldHaveDeduplicationKey bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
ExpectedAlertSourceID: "some-id",
|
||||||
|
ExpectedStatus: "firing",
|
||||||
|
ExpectedTitle: "Gatus: endpoint-name",
|
||||||
|
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
|
||||||
|
ShouldHaveDeduplicationKey: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
|
ExpectedAlertSourceID: "some-id",
|
||||||
|
ExpectedStatus: "resolved",
|
||||||
|
ExpectedTitle: "Gatus: endpoint-name",
|
||||||
|
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
|
||||||
|
ShouldHaveDeduplicationKey: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-with-metadata-source-url",
|
Name: "resolved-with-metadata-source-url",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
|
ExpectedAlertSourceID: "some-id",
|
||||||
|
ExpectedStatus: "resolved",
|
||||||
|
ExpectedTitle: "Gatus: endpoint-name",
|
||||||
|
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
|
||||||
|
ExpectedSourceURL: "some-source-url",
|
||||||
|
ExpectedMetadata: map[string]interface{}{"service": "some-service", "team": "very-core"},
|
||||||
|
ShouldHaveDeduplicationKey: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "group-override",
|
Name: "group-override",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
ExpectedAlertSourceID: "different-id",
|
||||||
|
ExpectedStatus: "firing",
|
||||||
|
ExpectedTitle: "Gatus: endpoint-name",
|
||||||
|
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
|
||||||
|
ShouldHaveDeduplicationKey: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,13 +261,42 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if string(body) != scenario.ExpectedBody {
|
|
||||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
// Parse the JSON body
|
||||||
}
|
var parsedBody Body
|
||||||
out := make(map[string]interface{})
|
if err := json.Unmarshal(body, &parsedBody); err != nil {
|
||||||
if err := json.Unmarshal(body, &out); err != nil {
|
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate individual fields
|
||||||
|
if parsedBody.AlertSourceConfigID != scenario.ExpectedAlertSourceID {
|
||||||
|
t.Errorf("expected alert_source_config_id to be %s, got %s", scenario.ExpectedAlertSourceID, parsedBody.AlertSourceConfigID)
|
||||||
|
}
|
||||||
|
if parsedBody.Status != scenario.ExpectedStatus {
|
||||||
|
t.Errorf("expected status to be %s, got %s", scenario.ExpectedStatus, parsedBody.Status)
|
||||||
|
}
|
||||||
|
if parsedBody.Title != scenario.ExpectedTitle {
|
||||||
|
t.Errorf("expected title to be %s, got %s", scenario.ExpectedTitle, parsedBody.Title)
|
||||||
|
}
|
||||||
|
if parsedBody.Description != scenario.ExpectedDescription {
|
||||||
|
t.Errorf("expected description to be %s, got %s", scenario.ExpectedDescription, parsedBody.Description)
|
||||||
|
}
|
||||||
|
if scenario.ExpectedSourceURL != "" && parsedBody.SourceURL != scenario.ExpectedSourceURL {
|
||||||
|
t.Errorf("expected source_url to be %s, got %s", scenario.ExpectedSourceURL, parsedBody.SourceURL)
|
||||||
|
}
|
||||||
|
if scenario.ExpectedMetadata != nil {
|
||||||
|
metadataJSON, _ := json.Marshal(parsedBody.Metadata)
|
||||||
|
expectedMetadataJSON, _ := json.Marshal(scenario.ExpectedMetadata)
|
||||||
|
if string(metadataJSON) != string(expectedMetadataJSON) {
|
||||||
|
t.Errorf("expected metadata to be %s, got %s", string(expectedMetadataJSON), string(metadataJSON))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validate that deduplication_key exists and is not empty
|
||||||
|
if scenario.ShouldHaveDeduplicationKey {
|
||||||
|
if parsedBody.DeduplicationKey == "" {
|
||||||
|
t.Error("expected deduplication_key to be present and non-empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package jetbrainsspace
|
package n8n
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -15,50 +15,36 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrProjectNotSet = errors.New("project not set")
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
ErrChannelIDNotSet = errors.New("channel-id not set")
|
|
||||||
ErrTokenNotSet = errors.New("token not set")
|
|
||||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Project string `yaml:"project"` // Project name
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
ChannelID string `yaml:"channel-id"` // Chat Channel ID
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
Token string `yaml:"token"` // Bearer Token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) Validate() error {
|
func (cfg *Config) Validate() error {
|
||||||
if len(cfg.Project) == 0 {
|
if len(cfg.WebhookURL) == 0 {
|
||||||
return ErrProjectNotSet
|
return ErrWebhookURLNotSet
|
||||||
}
|
|
||||||
if len(cfg.ChannelID) == 0 {
|
|
||||||
return ErrChannelIDNotSet
|
|
||||||
}
|
|
||||||
if len(cfg.Token) == 0 {
|
|
||||||
return ErrTokenNotSet
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) Merge(override *Config) {
|
func (cfg *Config) Merge(override *Config) {
|
||||||
if len(override.Project) > 0 {
|
if len(override.WebhookURL) > 0 {
|
||||||
cfg.Project = override.Project
|
cfg.WebhookURL = override.WebhookURL
|
||||||
}
|
}
|
||||||
if len(override.ChannelID) > 0 {
|
if len(override.Title) > 0 {
|
||||||
cfg.ChannelID = override.ChannelID
|
cfg.Title = override.Title
|
||||||
}
|
|
||||||
if len(override.Token) > 0 {
|
|
||||||
cfg.Token = override.Token
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
|
// AlertProvider is the configuration necessary for sending an alert using n8n
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
DefaultConfig Config `yaml:",inline"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
Overrides []Override `yaml:"overrides,omitempty"`
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -90,13 +76,11 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("Authorization", "Bearer "+cfg.Token)
|
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -110,78 +94,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Body struct {
|
type Body struct {
|
||||||
Channel string `json:"channel"`
|
Title string `json:"title"`
|
||||||
Content Content `json:"content"`
|
EndpointName string `json:"endpoint_name"`
|
||||||
|
EndpointGroup string `json:"endpoint_group,omitempty"`
|
||||||
|
EndpointURL string `json:"endpoint_url"`
|
||||||
|
AlertDescription string `json:"alert_description,omitempty"`
|
||||||
|
Resolved bool `json:"resolved"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ConditionResults []ConditionResult `json:"condition_results,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Content struct {
|
type ConditionResult struct {
|
||||||
ClassName string `json:"className"`
|
Condition string `json:"condition"`
|
||||||
Style string `json:"style"`
|
Success bool `json:"success"`
|
||||||
Sections []Section `json:"sections,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Section struct {
|
|
||||||
ClassName string `json:"className"`
|
|
||||||
Elements []Element `json:"elements"`
|
|
||||||
Header string `json:"header"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Element struct {
|
|
||||||
ClassName string `json:"className"`
|
|
||||||
Accessory Accessory `json:"accessory"`
|
|
||||||
Style string `json:"style"`
|
|
||||||
Size string `json:"size"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Accessory struct {
|
|
||||||
ClassName string `json:"className"`
|
|
||||||
Icon Icon `json:"icon"`
|
|
||||||
Style string `json:"style"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Icon struct {
|
|
||||||
Icon string `json:"icon"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
body := Body{
|
var message string
|
||||||
Channel: "id:" + cfg.ChannelID,
|
|
||||||
Content: Content{
|
|
||||||
ClassName: "ChatMessage.Block",
|
|
||||||
Sections: []Section{{
|
|
||||||
ClassName: "MessageSection",
|
|
||||||
Elements: []Element{},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if resolved {
|
if resolved {
|
||||||
body.Content.Style = "SUCCESS"
|
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
|
||||||
} else {
|
} else {
|
||||||
body.Content.Style = "WARNING"
|
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
|
||||||
}
|
}
|
||||||
|
title := "Gatus"
|
||||||
|
if cfg.Title != "" {
|
||||||
|
title = cfg.Title
|
||||||
|
}
|
||||||
|
var conditionResults []ConditionResult
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
icon := "warning"
|
conditionResults = append(conditionResults, ConditionResult{
|
||||||
style := "WARNING"
|
Condition: conditionResult.Condition,
|
||||||
if conditionResult.Success {
|
Success: conditionResult.Success,
|
||||||
icon = "success"
|
|
||||||
style = "SUCCESS"
|
|
||||||
}
|
|
||||||
body.Content.Sections[0].Elements = append(body.Content.Sections[0].Elements, Element{
|
|
||||||
ClassName: "MessageText",
|
|
||||||
Accessory: Accessory{
|
|
||||||
ClassName: "MessageIcon",
|
|
||||||
Icon: Icon{Icon: icon},
|
|
||||||
Style: style,
|
|
||||||
},
|
|
||||||
Style: style,
|
|
||||||
Size: "REGULAR",
|
|
||||||
Content: conditionResult.Condition,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
body := Body{
|
||||||
|
Title: title,
|
||||||
|
EndpointName: ep.Name,
|
||||||
|
EndpointGroup: ep.Group,
|
||||||
|
EndpointURL: ep.URL,
|
||||||
|
AlertDescription: alert.GetDescription(),
|
||||||
|
Resolved: resolved,
|
||||||
|
Message: message,
|
||||||
|
ConditionResults: conditionResults,
|
||||||
|
}
|
||||||
bodyAsJSON, _ := json.Marshal(body)
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
return bodyAsJSON
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
+106
-60
@@ -1,4 +1,4 @@
|
|||||||
package jetbrainsspace
|
package n8n
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_Validate(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if err := invalidProvider.Validate(); err == nil {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
|
||||||
if err := validProvider.Validate(); err != nil {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
@@ -24,10 +24,9 @@ func TestAlertProvider_Validate(t *testing.T) {
|
|||||||
|
|
||||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
DefaultConfig: Config{Project: "foobar"},
|
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Config: Config{ChannelID: "http://example.com"},
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -36,26 +35,21 @@ func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
|||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
DefaultConfig: Config{Project: "foobar"},
|
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Config: Config{ChannelID: ""},
|
Config: Config{WebhookURL: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider webhook URL shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
DefaultConfig: Config{
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Project: "foo",
|
|
||||||
ChannelID: "bar",
|
|
||||||
Token: "baz",
|
|
||||||
},
|
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Config: Config{ChannelID: "foobar"},
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -79,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -89,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -99,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -109,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -151,45 +145,94 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Endpoint endpoint.Endpoint
|
Endpoint endpoint.Endpoint
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedBody Body
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
ExpectedBody: Body{
|
||||||
|
Title: "Gatus",
|
||||||
|
EndpointName: "name",
|
||||||
|
EndpointURL: "https://example.org",
|
||||||
|
AlertDescription: "description-1",
|
||||||
|
Resolved: false,
|
||||||
|
Message: "An alert for name has been triggered due to having failed 3 time(s) in a row",
|
||||||
|
ConditionResults: []ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: false},
|
||||||
|
{Condition: "[STATUS] == 200", Success: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-with-group",
|
Name: "triggered-with-group",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group", URL: "https://example.org"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
ExpectedBody: Body{
|
||||||
|
Title: "Gatus",
|
||||||
|
EndpointName: "name",
|
||||||
|
EndpointGroup: "group",
|
||||||
|
EndpointURL: "https://example.org",
|
||||||
|
AlertDescription: "description-1",
|
||||||
|
Resolved: false,
|
||||||
|
Message: "An alert for group/name has been triggered due to having failed 3 time(s) in a row",
|
||||||
|
ConditionResults: []ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: false},
|
||||||
|
{Condition: "[STATUS] == 200", Success: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
ExpectedBody: Body{
|
||||||
|
Title: "Gatus",
|
||||||
|
EndpointName: "name",
|
||||||
|
EndpointURL: "https://example.org",
|
||||||
|
AlertDescription: "description-2",
|
||||||
|
Resolved: true,
|
||||||
|
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
|
||||||
|
ConditionResults: []ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: true},
|
||||||
|
{Condition: "[STATUS] == 200", Success: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-with-group",
|
Name: "resolved-with-custom-title",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "Custom Title"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
ExpectedBody: Body{
|
||||||
|
Title: "Custom Title",
|
||||||
|
EndpointName: "name",
|
||||||
|
EndpointURL: "https://example.org",
|
||||||
|
AlertDescription: "description-2",
|
||||||
|
Resolved: true,
|
||||||
|
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
|
||||||
|
ConditionResults: []ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: true},
|
||||||
|
{Condition: "[STATUS] == 200", Success: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("couldn't get config:", err.Error())
|
||||||
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&scenario.Provider.DefaultConfig,
|
cfg,
|
||||||
&scenario.Endpoint,
|
&scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -200,13 +243,22 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if string(body) != scenario.ExpectedBody {
|
var actualBody Body
|
||||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
if err := json.Unmarshal(body, &actualBody); err != nil {
|
||||||
}
|
|
||||||
out := make(map[string]interface{})
|
|
||||||
if err := json.Unmarshal(body, &out); err != nil {
|
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
|
if actualBody.Title != scenario.ExpectedBody.Title {
|
||||||
|
t.Errorf("expected title to be %s, got %s", scenario.ExpectedBody.Title, actualBody.Title)
|
||||||
|
}
|
||||||
|
if actualBody.EndpointName != scenario.ExpectedBody.EndpointName {
|
||||||
|
t.Errorf("expected endpoint name to be %s, got %s", scenario.ExpectedBody.EndpointName, actualBody.EndpointName)
|
||||||
|
}
|
||||||
|
if actualBody.Resolved != scenario.ExpectedBody.Resolved {
|
||||||
|
t.Errorf("expected resolved to be %v, got %v", scenario.ExpectedBody.Resolved, actualBody.Resolved)
|
||||||
|
}
|
||||||
|
if actualBody.Message != scenario.ExpectedBody.Message {
|
||||||
|
t.Errorf("expected message to be %s, got %s", scenario.ExpectedBody.Message, actualBody.Message)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,67 +283,67 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
InputAlert: alert.Alert{},
|
InputAlert: alert.Alert{},
|
||||||
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
InputAlert: alert.Alert{},
|
InputAlert: alert.Alert{},
|
||||||
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
Config: Config{ChannelID: "group-channel"},
|
Config: Config{WebhookURL: "http://example01.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
InputAlert: alert.Alert{},
|
InputAlert: alert.Alert{},
|
||||||
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
Config: Config{ChannelID: "group-channel"},
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
InputAlert: alert.Alert{},
|
InputAlert: alert.Alert{},
|
||||||
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
Config: Config{ChannelID: "group-channel"},
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
@@ -300,14 +352,8 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %s", err)
|
t.Fatalf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
}
|
|
||||||
if got.Project != scenario.ExpectedOutput.Project {
|
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
|
|
||||||
}
|
|
||||||
if got.Token != scenario.ExpectedOutput.Token {
|
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
|
||||||
}
|
}
|
||||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
@@ -3,6 +3,7 @@ package provider
|
|||||||
import (
|
import (
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
@@ -16,11 +17,11 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||||
@@ -92,6 +93,7 @@ func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAl
|
|||||||
var (
|
var (
|
||||||
// Validate provider interface implementation on compile
|
// Validate provider interface implementation on compile
|
||||||
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*clickup.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*datadog.AlertProvider)(nil)
|
_ AlertProvider = (*datadog.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||||
@@ -105,11 +107,11 @@ var (
|
|||||||
_ AlertProvider = (*ifttt.AlertProvider)(nil)
|
_ AlertProvider = (*ifttt.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*ilert.AlertProvider)(nil)
|
_ AlertProvider = (*ilert.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*incidentio.AlertProvider)(nil)
|
_ AlertProvider = (*incidentio.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
|
||||||
_ AlertProvider = (*line.AlertProvider)(nil)
|
_ AlertProvider = (*line.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*n8n.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*newrelic.AlertProvider)(nil)
|
_ AlertProvider = (*newrelic.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*ntfy.AlertProvider)(nil)
|
_ AlertProvider = (*ntfy.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
|
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
|
||||||
@@ -133,6 +135,7 @@ var (
|
|||||||
|
|
||||||
// Validate config interface implementation on compile
|
// Validate config interface implementation on compile
|
||||||
_ Config[awsses.Config] = (*awsses.Config)(nil)
|
_ Config[awsses.Config] = (*awsses.Config)(nil)
|
||||||
|
_ Config[clickup.Config] = (*clickup.Config)(nil)
|
||||||
_ Config[custom.Config] = (*custom.Config)(nil)
|
_ Config[custom.Config] = (*custom.Config)(nil)
|
||||||
_ Config[datadog.Config] = (*datadog.Config)(nil)
|
_ Config[datadog.Config] = (*datadog.Config)(nil)
|
||||||
_ Config[discord.Config] = (*discord.Config)(nil)
|
_ Config[discord.Config] = (*discord.Config)(nil)
|
||||||
@@ -146,11 +149,11 @@ var (
|
|||||||
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
|
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
|
||||||
_ Config[ilert.Config] = (*ilert.Config)(nil)
|
_ Config[ilert.Config] = (*ilert.Config)(nil)
|
||||||
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
|
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
|
||||||
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
|
|
||||||
_ Config[line.Config] = (*line.Config)(nil)
|
_ Config[line.Config] = (*line.Config)(nil)
|
||||||
_ Config[matrix.Config] = (*matrix.Config)(nil)
|
_ Config[matrix.Config] = (*matrix.Config)(nil)
|
||||||
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
|
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
|
||||||
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
|
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
|
||||||
|
_ Config[n8n.Config] = (*n8n.Config)(nil)
|
||||||
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
|
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
|
||||||
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
|
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
|
||||||
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
|
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
|||||||
}
|
}
|
||||||
var text string
|
var text string
|
||||||
if len(alert.GetDescription()) > 0 {
|
if len(alert.GetDescription()) > 0 {
|
||||||
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
|
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n%s \n%s", message, alert.GetDescription(), formattedConditionResults)
|
||||||
} else {
|
} else {
|
||||||
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
|
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
|
descriptionWithLink := "[link](https://example.org/)"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
@@ -137,14 +138,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\ndescription-2 \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-with-no-conditions",
|
Name: "resolved-with-no-conditions",
|
||||||
@@ -152,14 +153,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\ndescription-2 \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "send to topic",
|
Name: "send to topic",
|
||||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123", TopicID: "7"}},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123", TopicID: "7"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
|
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* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered with link in description",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||||
|
Alert: alert.Alert{Description: &descriptionWithLink, 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[link](https://example.org/) \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
@@ -27,6 +28,10 @@ type Config struct {
|
|||||||
BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
|
BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
|
||||||
Domain string `yaml:"domain"` // Domain of the Zulip server
|
Domain string `yaml:"domain"` // Domain of the Zulip server
|
||||||
ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
|
ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
|
||||||
|
Topic string `yaml:"topic,omitempty"` // Topic to send the message to; defaults to "Gatus" when not set
|
||||||
|
|
||||||
|
// The following placeholders are supported in Topic:
|
||||||
|
// [ENDPOINT_NAME], [ENDPOINT_GROUP], [ALERT_DESCRIPTION]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) Validate() error {
|
func (cfg *Config) Validate() error {
|
||||||
@@ -58,6 +63,9 @@ func (cfg *Config) Merge(override *Config) {
|
|||||||
if len(override.ChannelID) > 0 {
|
if len(override.ChannelID) > 0 {
|
||||||
cfg.ChannelID = override.ChannelID
|
cfg.ChannelID = override.ChannelID
|
||||||
}
|
}
|
||||||
|
if len(override.Topic) > 0 {
|
||||||
|
cfg.Topic = override.Topic
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Zulip
|
// AlertProvider is the configuration necessary for sending an alert using Zulip
|
||||||
@@ -138,10 +146,17 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
|||||||
}
|
}
|
||||||
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
|
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
|
topic := cfg.Topic
|
||||||
|
if len(topic) == 0 {
|
||||||
|
topic = "Gatus"
|
||||||
|
}
|
||||||
|
topic = strings.ReplaceAll(topic, "[ENDPOINT_NAME]", ep.Name)
|
||||||
|
topic = strings.ReplaceAll(topic, "[ENDPOINT_GROUP]", ep.Group)
|
||||||
|
topic = strings.ReplaceAll(topic, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||||
return url.Values{
|
return url.Values{
|
||||||
"type": {"channel"},
|
"type": {"channel"},
|
||||||
"to": {cfg.ChannelID},
|
"to": {cfg.ChannelID},
|
||||||
"topic": {"Gatus"},
|
"topic": {topic},
|
||||||
"content": {message},
|
"content": {message},
|
||||||
}.Encode()
|
}.Encode()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,52 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
"type": {"channel"},
|
"type": {"channel"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Failed alert with custom topic",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "bot-email",
|
||||||
|
BotAPIKey: "bot-api-key",
|
||||||
|
Domain: "domain",
|
||||||
|
ChannelID: "channel-id",
|
||||||
|
Topic: "alerts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: false,
|
||||||
|
hasConditions: false,
|
||||||
|
expectedBody: url.Values{
|
||||||
|
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
|
||||||
|
> Description
|
||||||
|
`},
|
||||||
|
"to": {"channel-id"},
|
||||||
|
"topic": {"alerts"},
|
||||||
|
"type": {"channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Failed alert with templated topic",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "bot-email",
|
||||||
|
BotAPIKey: "bot-api-key",
|
||||||
|
Domain: "domain",
|
||||||
|
ChannelID: "channel-id",
|
||||||
|
Topic: "[ENDPOINT_NAME]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: false,
|
||||||
|
hasConditions: false,
|
||||||
|
expectedBody: url.Values{
|
||||||
|
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
|
||||||
|
> Description
|
||||||
|
`},
|
||||||
|
"to": {"channel-id"},
|
||||||
|
"topic": {"endpoint-Name"},
|
||||||
|
"type": {"channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Resolved alert with conditions",
|
name: "Resolved alert with conditions",
|
||||||
provider: AlertProvider{
|
provider: AlertProvider{
|
||||||
@@ -446,12 +492,14 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
|||||||
"bot-api-key": "alert-bot-api-key",
|
"bot-api-key": "alert-bot-api-key",
|
||||||
"domain": "alert-domain",
|
"domain": "alert-domain",
|
||||||
"channel-id": "alert-channel-id",
|
"channel-id": "alert-channel-id",
|
||||||
|
"topic": "alert-topic",
|
||||||
}},
|
}},
|
||||||
ExpectedOutput: Config{
|
ExpectedOutput: Config{
|
||||||
BotEmail: "alert-bot-email",
|
BotEmail: "alert-bot-email",
|
||||||
BotAPIKey: "alert-bot-api-key",
|
BotAPIKey: "alert-bot-api-key",
|
||||||
Domain: "alert-domain",
|
Domain: "alert-domain",
|
||||||
ChannelID: "alert-channel-id",
|
ChannelID: "alert-channel-id",
|
||||||
|
Topic: "alert-topic",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -473,6 +521,9 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
|||||||
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
||||||
}
|
}
|
||||||
|
if got.Topic != scenario.ExpectedOutput.Topic {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Topic, got.Topic)
|
||||||
|
}
|
||||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
t.Errorf("unexpected error: %s", err)
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
|||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
|
||||||
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/history", ResponseTimeHistory)
|
||||||
// This endpoint requires authz with bearer token, so technically it is protected
|
// This endpoint requires authz with bearer token, so technically it is protected
|
||||||
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
||||||
// SPA
|
// SPA
|
||||||
|
|||||||
+1
-1
@@ -299,7 +299,7 @@ func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Con
|
|||||||
thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds
|
thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds
|
||||||
}
|
}
|
||||||
// the threshold config requires 5 values, so we can be sure it's set here
|
// the threshold config requires 5 values, so we can be sure it's set here
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
if responseTime <= thresholds[i] {
|
if responseTime <= thresholds[i] {
|
||||||
return badgeColors[i]
|
return badgeColors[i]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,3 +126,63 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ResponseTimeHistory(c *fiber.Ctx) error {
|
||||||
|
duration := c.Params("duration")
|
||||||
|
var from time.Time
|
||||||
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
|
||||||
|
case "7d":
|
||||||
|
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
|
||||||
|
case "24h":
|
||||||
|
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||||
|
default:
|
||||||
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
|
||||||
|
}
|
||||||
|
endpointKey, err := url.QueryUnescape(c.Params("key"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).SendString("invalid key encoding")
|
||||||
|
}
|
||||||
|
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(endpointKey, from, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
return c.Status(404).SendString(err.Error())
|
||||||
|
}
|
||||||
|
if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
|
return c.Status(400).SendString(err.Error())
|
||||||
|
}
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
if len(hourlyAverageResponseTime) == 0 {
|
||||||
|
return c.Status(200).JSON(map[string]interface{}{
|
||||||
|
"timestamps": []int64{},
|
||||||
|
"values": []int{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hourlyTimestamps := make([]int, 0, len(hourlyAverageResponseTime))
|
||||||
|
earliestTimestamp := int64(0)
|
||||||
|
for hourlyTimestamp := range hourlyAverageResponseTime {
|
||||||
|
hourlyTimestamps = append(hourlyTimestamps, int(hourlyTimestamp))
|
||||||
|
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
|
||||||
|
earliestTimestamp = hourlyTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for earliestTimestamp > from.Unix() {
|
||||||
|
earliestTimestamp -= int64(time.Hour.Seconds())
|
||||||
|
hourlyTimestamps = append(hourlyTimestamps, int(earliestTimestamp))
|
||||||
|
}
|
||||||
|
sort.Ints(hourlyTimestamps)
|
||||||
|
timestamps := make([]int64, 0, len(hourlyTimestamps))
|
||||||
|
values := make([]int, 0, len(hourlyTimestamps))
|
||||||
|
for _, hourlyTimestamp := range hourlyTimestamps {
|
||||||
|
timestamp := int64(hourlyTimestamp)
|
||||||
|
averageResponseTime := hourlyAverageResponseTime[timestamp]
|
||||||
|
timestamps = append(timestamps, timestamp*1000)
|
||||||
|
values = append(values, averageResponseTime)
|
||||||
|
}
|
||||||
|
return c.Status(http.StatusOK).JSON(map[string]interface{}{
|
||||||
|
"timestamps": timestamps,
|
||||||
|
"values": values,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,3 +81,69 @@ func TestResponseTimeChart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponseTimeHistory(t *testing.T) {
|
||||||
|
defer store.Get().Clear()
|
||||||
|
defer cache.Clear()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Metrics: true,
|
||||||
|
Endpoints: []*endpoint.Endpoint{
|
||||||
|
{
|
||||||
|
Name: "frontend",
|
||||||
|
Group: "core",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "backend",
|
||||||
|
Group: "core",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||||
|
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||||
|
api := New(cfg)
|
||||||
|
router := api.Router()
|
||||||
|
type Scenario struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
ExpectedCode int
|
||||||
|
}
|
||||||
|
scenarios := []Scenario{
|
||||||
|
{
|
||||||
|
Name: "history-response-time-24h",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/response-times/24h/history",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "history-response-time-7d",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/response-times/7d/history",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "history-response-time-30d",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/response-times/30d/history",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "history-response-time-with-invalid-duration",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/response-times/3d/history",
|
||||||
|
ExpectedCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "history-response-time-for-invalid-key",
|
||||||
|
Path: "/api/v1/endpoints/invalid_key/response-times/7d/history",
|
||||||
|
ExpectedCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||||
|
response, err := router.Test(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != scenario.ExpectedCode {
|
||||||
|
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/security"
|
"github.com/TwiN/gatus/v5/security"
|
||||||
@@ -27,7 +28,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
|
|||||||
t.Error("expected err to be nil, but was", err)
|
t.Error("expected err to be nil, but was", err)
|
||||||
}
|
}
|
||||||
// Test the config handler
|
// Test the config handler
|
||||||
request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody)
|
request := httptest.NewRequest("GET", "/api/v1/config", http.NoBody)
|
||||||
response, err := app.Test(request)
|
response, err := app.Test(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error("expected err to be nil, but was", err)
|
t.Error("expected err to be nil, but was", err)
|
||||||
|
|||||||
@@ -68,11 +68,20 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
|||||||
return c.Status(500).SendString(err.Error())
|
return c.Status(500).SendString(err.Error())
|
||||||
}
|
}
|
||||||
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
|
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
|
||||||
|
inEndpointMaintenanceWindow := false
|
||||||
|
for _, maintenanceWindow := range externalEndpoint.MaintenanceWindows {
|
||||||
|
if maintenanceWindow.IsUnderMaintenance() {
|
||||||
|
logr.Debug("[api.CreateExternalEndpointResult] Under endpoint maintenance window")
|
||||||
|
inEndpointMaintenanceWindow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
// Check if an alert should be triggered or resolved
|
// Check if an alert should be triggered or resolved
|
||||||
if !cfg.Maintenance.IsUnderMaintenance() {
|
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
|
||||||
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
|
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
|
||||||
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
||||||
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||||
|
} else {
|
||||||
|
logr.Debug("[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window")
|
||||||
}
|
}
|
||||||
if cfg.Metrics {
|
if cfg.Metrics {
|
||||||
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
|
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
|
||||||
|
|||||||
+9
-2
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
@@ -25,7 +26,10 @@ func UptimeRaw(c *fiber.Ctx) error {
|
|||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||||
}
|
}
|
||||||
key := c.Params("key")
|
key, err := url.QueryUnescape(c.Params("key"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).SendString("invalid key encoding")
|
||||||
|
}
|
||||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
@@ -57,7 +61,10 @@ func ResponseTimeRaw(c *fiber.Ctx) error {
|
|||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||||
}
|
}
|
||||||
key := c.Params("key")
|
key, err := url.QueryUnescape(c.Params("key"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).SendString("invalid key encoding")
|
||||||
|
}
|
||||||
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
|||||||
+85
-38
@@ -1,6 +1,7 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,13 +21,13 @@ import (
|
|||||||
"github.com/TwiN/gocache/v2"
|
"github.com/TwiN/gocache/v2"
|
||||||
"github.com/TwiN/logr"
|
"github.com/TwiN/logr"
|
||||||
"github.com/TwiN/whois"
|
"github.com/TwiN/whois"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
"github.com/ishidawataru/sctp"
|
"github.com/ishidawataru/sctp"
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
ping "github.com/prometheus-community/pro-bing"
|
ping "github.com/prometheus-community/pro-bing"
|
||||||
"github.com/registrobr/rdap"
|
"github.com/registrobr/rdap"
|
||||||
"github.com/registrobr/rdap/protocol"
|
"github.com/registrobr/rdap/protocol"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/net/websocket"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -246,7 +248,7 @@ func CanPerformTLS(address string, body string, config *Config) (connected bool,
|
|||||||
|
|
||||||
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
|
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
|
||||||
// using the SSH protocol.
|
// using the SSH protocol.
|
||||||
func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.Client, error) {
|
func CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {
|
||||||
var port string
|
var port string
|
||||||
if strings.Contains(address, ":") {
|
if strings.Contains(address, ":") {
|
||||||
addressAndPort := strings.Split(address, ":")
|
addressAndPort := strings.Split(address, ":")
|
||||||
@@ -258,12 +260,24 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
|
|||||||
} else {
|
} else {
|
||||||
port = "22"
|
port = "22"
|
||||||
}
|
}
|
||||||
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
|
|
||||||
|
// Build auth methods: prefer parsed private key if provided, fall back to password.
|
||||||
|
var authMethods []ssh.AuthMethod
|
||||||
|
if len(privateKey) > 0 {
|
||||||
|
if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
|
||||||
|
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||||
|
} else {
|
||||||
|
return false, nil, fmt.Errorf("invalid private key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(password) > 0 {
|
||||||
|
authMethods = append(authMethods, ssh.Password(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
User: username,
|
User: username,
|
||||||
Auth: []ssh.AuthMethod{
|
Auth: authMethods,
|
||||||
ssh.Password(password),
|
|
||||||
},
|
|
||||||
Timeout: config.Timeout,
|
Timeout: config.Timeout,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -301,7 +315,7 @@ func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
|
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
|
||||||
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
|
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, []byte, error) {
|
||||||
type Body struct {
|
type Body struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
}
|
}
|
||||||
@@ -309,26 +323,30 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
|
|||||||
var b Body
|
var b Body
|
||||||
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
|
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
|
||||||
if err := json.Unmarshal([]byte(body), &b); err != nil {
|
if err := json.Unmarshal([]byte(body), &b); err != nil {
|
||||||
return false, 0, err
|
return false, 0, nil, err
|
||||||
}
|
}
|
||||||
sess, err := sshClient.NewSession()
|
sess, err := sshClient.NewSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 0, err
|
return false, 0, nil, err
|
||||||
}
|
}
|
||||||
|
// Capture stdout
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
sess.Stdout = &stdout
|
||||||
err = sess.Start(b.Command)
|
err = sess.Start(b.Command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 0, err
|
return false, 0, nil, err
|
||||||
}
|
}
|
||||||
defer sess.Close()
|
defer sess.Close()
|
||||||
err = sess.Wait()
|
err = sess.Wait()
|
||||||
|
output := stdout.Bytes()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return true, 0, nil
|
return true, 0, output, nil
|
||||||
}
|
}
|
||||||
var exitErr *ssh.ExitError
|
var exitErr *ssh.ExitError
|
||||||
if ok := errors.As(err, &exitErr); !ok {
|
if ok := errors.As(err, &exitErr); !ok {
|
||||||
return false, 0, err
|
return false, 0, nil, err
|
||||||
}
|
}
|
||||||
return true, exitErr.ExitStatus(), nil
|
return true, exitErr.ExitStatus(), output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
||||||
@@ -338,12 +356,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
|||||||
pinger := ping.New(address)
|
pinger := ping.New(address)
|
||||||
pinger.Count = 1
|
pinger.Count = 1
|
||||||
pinger.Timeout = config.Timeout
|
pinger.Timeout = config.Timeout
|
||||||
// Set the pinger's privileged mode to true for every GOOS except darwin
|
pinger.SetPrivileged(ShouldRunPingerAsPrivileged())
|
||||||
// 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/prometheus-community/pro-bing#linux
|
|
||||||
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
|
||||||
pinger.SetNetwork(config.Network)
|
pinger.SetNetwork(config.Network)
|
||||||
err := pinger.Run()
|
err := pinger.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -359,51 +372,75 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
|||||||
return true, 0
|
return true, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShouldRunPingerAsPrivileged will determine whether or not to run pinger in privileged mode.
|
||||||
|
// It should be set to privileged when running as root, and always on windows. See https://pkg.go.dev/github.com/macrat/go-parallel-pinger#Pinger.SetPrivileged
|
||||||
|
func ShouldRunPingerAsPrivileged() bool {
|
||||||
|
// Set the pinger's privileged mode to false for darwin
|
||||||
|
// See https://github.com/TwiN/gatus/issues/132
|
||||||
|
// linux should also be set to false, but there are potential complications
|
||||||
|
// See https://github.com/TwiN/gatus/pull/748 and https://github.com/TwiN/gatus/issues/697#issuecomment-2081700989
|
||||||
|
//
|
||||||
|
// Note that for this to work on Linux, Gatus must run with sudo privileges. (in certain cases)
|
||||||
|
// See https://github.com/prometheus-community/pro-bing#linux
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// To actually check for cap_net_raw capabilities, we would need to add "kernel.org/pub/linux/libs/security/libcap/cap" to gatus.
|
||||||
|
// Or use a syscall and check for permission errors, but this requires platform specific compilation
|
||||||
|
// As a backstop we can simply check the effective user id and run as privileged when running as root
|
||||||
|
return os.Geteuid() == 0
|
||||||
|
}
|
||||||
|
|
||||||
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
|
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
|
||||||
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
|
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
|
||||||
const (
|
const (
|
||||||
Origin = "http://localhost/"
|
Origin = "http://localhost/"
|
||||||
MaximumMessageSize = 1024 // in bytes
|
|
||||||
)
|
)
|
||||||
wsConfig, err := websocket.NewConfig(address, Origin)
|
var (
|
||||||
if err != nil {
|
dialer = websocket.Dialer{
|
||||||
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err)
|
EnableCompression: true,
|
||||||
}
|
|
||||||
if headers != nil {
|
|
||||||
if wsConfig.Header == nil {
|
|
||||||
wsConfig.Header = make(http.Header)
|
|
||||||
}
|
}
|
||||||
|
wsHeaders = make(http.Header)
|
||||||
|
)
|
||||||
|
|
||||||
|
wsHeaders.Set("Origin", Origin)
|
||||||
for name, value := range headers {
|
for name, value := range headers {
|
||||||
wsConfig.Header.Set(name, value)
|
wsHeaders.Set(name, value)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
if config != nil {
|
if config != nil {
|
||||||
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
|
if config.Timeout > 0 {
|
||||||
wsConfig.TlsConfig = &tls.Config{
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, config.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
dialer.TLSClientConfig = &tls.Config{
|
||||||
InsecureSkipVerify: config.Insecure,
|
InsecureSkipVerify: config.Insecure,
|
||||||
}
|
}
|
||||||
if config.HasTLSConfig() && config.TLS.isValid() == nil {
|
if config.HasTLSConfig() && config.TLS.isValid() == nil {
|
||||||
wsConfig.TlsConfig = configureTLS(wsConfig.TlsConfig, *config.TLS)
|
dialer.TLSClientConfig = configureTLS(dialer.TLSClientConfig, *config.TLS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dial URL
|
// Dial URL
|
||||||
ws, err := websocket.DialConfig(wsConfig)
|
ws, _, err := dialer.DialContext(ctx, address, wsHeaders)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
|
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
|
||||||
}
|
}
|
||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
|
body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
|
||||||
// Write message
|
// Write message
|
||||||
if _, err := ws.Write([]byte(body)); err != nil {
|
if err := ws.WriteMessage(websocket.TextMessage, []byte(body)); err != nil {
|
||||||
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
|
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
|
||||||
}
|
}
|
||||||
// Read message
|
// Read message
|
||||||
var n int
|
msgType, msg, err := ws.ReadMessage()
|
||||||
msg := make([]byte, MaximumMessageSize)
|
if err != nil {
|
||||||
if n, err = ws.Read(msg); err != nil {
|
|
||||||
return false, nil, fmt.Errorf("error reading websocket message: %w", err)
|
return false, nil, fmt.Errorf("error reading websocket message: %w", err)
|
||||||
|
} else if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {
|
||||||
|
return false, nil, fmt.Errorf("unexpected websocket message type: %d, expected %d or %d", msgType, websocket.TextMessage, websocket.BinaryMessage)
|
||||||
}
|
}
|
||||||
return true, msg[:n], nil
|
return true, msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
|
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
|
||||||
@@ -462,6 +499,13 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
|
|||||||
if srv, ok := rr.(*dns.SRV); ok {
|
if srv, ok := rr.(*dns.SRV); ok {
|
||||||
body = []byte(fmt.Sprintf("%s:%d", srv.Target, srv.Port))
|
body = []byte(fmt.Sprintf("%s:%d", srv.Target, srv.Port))
|
||||||
}
|
}
|
||||||
|
case dns.TypeTXT:
|
||||||
|
if txt, ok := rr.(*dns.TXT); ok {
|
||||||
|
if len(body) > 0 {
|
||||||
|
body = append(body, '\n')
|
||||||
|
}
|
||||||
|
body = append(body, []byte(strings.Join(txt.Txt, ""))...)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
body = []byte("query type is not supported yet")
|
body = []byte("query type is not supported yet")
|
||||||
}
|
}
|
||||||
@@ -491,6 +535,9 @@ func rdapQuery(hostname string) (*whois.Response, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if response.ExpirationDate.IsZero() {
|
||||||
|
return nil, fmt.Errorf("no expiration event found in RDAP response for %s", hostname)
|
||||||
|
}
|
||||||
return &response, nil
|
return &response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+84
-16
@@ -6,6 +6,9 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +17,17 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func isIgnorableNetworkTestError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
errString := err.Error()
|
||||||
|
return strings.Contains(errString, "no such host") ||
|
||||||
|
strings.Contains(errString, "connection reset by peer") ||
|
||||||
|
strings.Contains(errString, "i/o timeout") ||
|
||||||
|
strings.Contains(errString, "server misbehaving")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetHTTPClient(t *testing.T) {
|
func TestGetHTTPClient(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Insecure: false,
|
Insecure: false,
|
||||||
@@ -40,6 +54,7 @@ func TestGetHTTPClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRdapQuery(t *testing.T) {
|
func TestRdapQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
if _, err := rdapQuery("1.1.1.1"); err == nil {
|
if _, err := rdapQuery("1.1.1.1"); err == nil {
|
||||||
t.Error("expected an error due to the invalid domain type")
|
t.Error("expected an error due to the invalid domain type")
|
||||||
}
|
}
|
||||||
@@ -51,6 +66,13 @@ func TestRdapQuery(t *testing.T) {
|
|||||||
} else if response.ExpirationDate.Unix() <= 0 {
|
} else if response.ExpirationDate.Unix() <= 0 {
|
||||||
t.Error("expected to have a valid expiry date, got", response.ExpirationDate.Unix())
|
t.Error("expected to have a valid expiry date, got", response.ExpirationDate.Unix())
|
||||||
}
|
}
|
||||||
|
// .net domain: rdapQuery must either return a valid expiration or an error,
|
||||||
|
// but never silently return a zero-value expiration date (the bug in #1570)
|
||||||
|
if response, err := rdapQuery("google.net"); err != nil {
|
||||||
|
t.Logf("rdapQuery returned error for .net domain (fallback to WHOIS expected): %s", err)
|
||||||
|
} else if response.ExpirationDate.IsZero() {
|
||||||
|
t.Error("rdapQuery returned a zero expiration date without an error for .net domain")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetDomainExpiration(t *testing.T) {
|
func TestGetDomainExpiration(t *testing.T) {
|
||||||
@@ -79,6 +101,12 @@ func TestGetDomainExpiration(t *testing.T) {
|
|||||||
} else if domainExpiration <= 0 {
|
} else if domainExpiration <= 0 {
|
||||||
t.Error("expected domain expiration to be higher than 0")
|
t.Error("expected domain expiration to be higher than 0")
|
||||||
}
|
}
|
||||||
|
// .net domain: should succeed via RDAP or WHOIS fallback (#1570)
|
||||||
|
if domainExpiration, err := GetDomainExpiration("google.net"); err != nil {
|
||||||
|
t.Errorf("expected error to be nil for .net domain, but got: `%s`", err)
|
||||||
|
} else if domainExpiration <= 0 {
|
||||||
|
t.Error("expected domain expiration to be higher than 0 for .net domain")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPing(t *testing.T) {
|
func TestPing(t *testing.T) {
|
||||||
@@ -129,6 +157,32 @@ func TestPing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldRunPingerAsPrivileged(t *testing.T) {
|
||||||
|
// Don't run in parallel since we're testing system-dependent behavior
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
result := ShouldRunPingerAsPrivileged()
|
||||||
|
if !result {
|
||||||
|
t.Error("On Windows, ShouldRunPingerAsPrivileged() should return true")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-Windows tests
|
||||||
|
result := ShouldRunPingerAsPrivileged()
|
||||||
|
isRoot := os.Geteuid() == 0
|
||||||
|
|
||||||
|
// Test cases based on current environment
|
||||||
|
if isRoot {
|
||||||
|
if !result {
|
||||||
|
t.Error("When running as root, ShouldRunPingerAsPrivileged() should return true")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// When not root, the result depends on raw socket creation
|
||||||
|
// We can at least verify the function runs without panic
|
||||||
|
t.Logf("Non-root privileged result: %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCanPerformStartTLS(t *testing.T) {
|
func TestCanPerformStartTLS(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
address string
|
address string
|
||||||
@@ -179,6 +233,9 @@ func TestCanPerformStartTLS(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, DNSResolver: tt.args.dnsresolver})
|
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, DNSResolver: tt.args.dnsresolver})
|
||||||
|
if !tt.wantErr && isIgnorableNetworkTestError(err) {
|
||||||
|
t.Skipf("skipping due to transient network error: %v", err)
|
||||||
|
}
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
@@ -248,6 +305,9 @@ func TestCanPerformTLS(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
|
connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
|
||||||
|
if !tt.wantErr && isIgnorableNetworkTestError(err) {
|
||||||
|
t.Skipf("skipping due to transient network error: %v", err)
|
||||||
|
}
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
@@ -260,6 +320,7 @@ func TestCanPerformTLS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCanCreateConnection(t *testing.T) {
|
func TestCanCreateConnection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second})
|
connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second})
|
||||||
if connected {
|
if connected {
|
||||||
t.Error("should've failed, because there's no port in the address")
|
t.Error("should've failed, because there's no port in the address")
|
||||||
@@ -329,6 +390,7 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryWebSocket(t *testing.T) {
|
func TestQueryWebSocket(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
_, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second})
|
_, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected an error due to the address being invalid")
|
t.Error("expected an error due to the address being invalid")
|
||||||
@@ -340,7 +402,8 @@ func TestQueryWebSocket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTlsRenegotiation(t *testing.T) {
|
func TestTlsRenegotiation(t *testing.T) {
|
||||||
tests := []struct {
|
t.Parallel()
|
||||||
|
scenarios := []struct {
|
||||||
name string
|
name string
|
||||||
cfg TLSConfig
|
cfg TLSConfig
|
||||||
expectedConfig tls.RenegotiationSupport
|
expectedConfig tls.RenegotiationSupport
|
||||||
@@ -371,18 +434,19 @@ func TestTlsRenegotiation(t *testing.T) {
|
|||||||
expectedConfig: tls.RenegotiateNever,
|
expectedConfig: tls.RenegotiateNever,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
tls := &tls.Config{}
|
tls := &tls.Config{}
|
||||||
tlsConfig := configureTLS(tls, test.cfg)
|
tlsConfig := configureTLS(tls, scenario.cfg)
|
||||||
if tlsConfig.Renegotiation != test.expectedConfig {
|
if tlsConfig.Renegotiation != scenario.expectedConfig {
|
||||||
t.Errorf("expected tls renegotiation to be %v, but got %v", test.expectedConfig, tls.Renegotiation)
|
t.Errorf("expected tls renegotiation to be %v, but got %v", scenario.expectedConfig, tls.Renegotiation)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryDNS(t *testing.T) {
|
func TestQueryDNS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
name string
|
name string
|
||||||
inputDNS dns.Config
|
inputDNS dns.Config
|
||||||
@@ -439,7 +503,7 @@ func TestQueryDNS(t *testing.T) {
|
|||||||
},
|
},
|
||||||
inputURL: "8.8.8.8",
|
inputURL: "8.8.8.8",
|
||||||
expectedDNSCode: "NOERROR",
|
expectedDNSCode: "NOERROR",
|
||||||
expectedBody: "*.iana-servers.net.",
|
expectedBody: "*.ns.cloudflare.com.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "test Config with type PTR",
|
name: "test Config with type PTR",
|
||||||
@@ -461,6 +525,16 @@ func TestQueryDNS(t *testing.T) {
|
|||||||
expectedDNSCode: "NOERROR",
|
expectedDNSCode: "NOERROR",
|
||||||
expectedBody: "one.one.one.one.",
|
expectedBody: "one.one.one.one.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "test Config with type TXT",
|
||||||
|
inputDNS: dns.Config{
|
||||||
|
QueryType: "TXT",
|
||||||
|
QueryName: "example.com.",
|
||||||
|
},
|
||||||
|
inputURL: "1.1.1.1",
|
||||||
|
expectedDNSCode: "NOERROR",
|
||||||
|
expectedBody: "*v=spf1*",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "test Config with fake type and retrieve error",
|
name: "test Config with fake type and retrieve error",
|
||||||
inputDNS: dns.Config{
|
inputDNS: dns.Config{
|
||||||
@@ -480,8 +554,8 @@ func TestQueryDNS(t *testing.T) {
|
|||||||
if dnsRCode != scenario.expectedDNSCode {
|
if dnsRCode != scenario.expectedDNSCode {
|
||||||
t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode)
|
t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode)
|
||||||
}
|
}
|
||||||
if scenario.inputDNS.QueryType == "NS" {
|
if scenario.inputDNS.QueryType == "NS" || scenario.inputDNS.QueryType == "TXT" {
|
||||||
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
|
// Some record types can have multiple valid answers, so wildcard matching is used in those scenarios
|
||||||
if !pattern.Match(scenario.expectedBody, string(body)) {
|
if !pattern.Match(scenario.expectedBody, string(body)) {
|
||||||
t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody)
|
t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody)
|
||||||
}
|
}
|
||||||
@@ -512,15 +586,13 @@ func TestQueryDNS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckSSHBanner(t *testing.T) {
|
func TestCheckSSHBanner(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
cfg := &Config{Timeout: 3}
|
cfg := &Config{Timeout: 3}
|
||||||
|
|
||||||
t.Run("no-auth-ssh", func(t *testing.T) {
|
t.Run("no-auth-ssh", func(t *testing.T) {
|
||||||
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
|
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected: error != nil, got: %v ", err)
|
t.Errorf("Expected: error != nil, got: %v ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if connected == false {
|
if connected == false {
|
||||||
t.Errorf("Expected: connected == true, got: %v", connected)
|
t.Errorf("Expected: connected == true, got: %v", connected)
|
||||||
}
|
}
|
||||||
@@ -528,14 +600,11 @@ func TestCheckSSHBanner(t *testing.T) {
|
|||||||
t.Errorf("Expected: 0, got: %v", status)
|
t.Errorf("Expected: 0, got: %v", status)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("invalid-address", func(t *testing.T) {
|
t.Run("invalid-address", func(t *testing.T) {
|
||||||
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
|
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("Expected: error, got: %v ", err)
|
t.Errorf("Expected: error, got: %v ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if connected != false {
|
if connected != false {
|
||||||
t.Errorf("Expected: connected == false, got: %v", connected)
|
t.Errorf("Expected: connected == false, got: %v", connected)
|
||||||
}
|
}
|
||||||
@@ -543,5 +612,4 @@ func TestCheckSSHBanner(t *testing.T) {
|
|||||||
t.Errorf("Expected: 1, got: %v", status)
|
t.Errorf("Expected: 1, got: %v", status)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -34,6 +35,7 @@ var (
|
|||||||
IgnoreRedirect: false,
|
IgnoreRedirect: false,
|
||||||
Timeout: defaultTimeout,
|
Timeout: defaultTimeout,
|
||||||
Network: "ip",
|
Network: "ip",
|
||||||
|
StoreCookies: false,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,6 +84,9 @@ type Config struct {
|
|||||||
// ResolvedTunnel is the resolved SSH tunnel for this specific Config
|
// ResolvedTunnel is the resolved SSH tunnel for this specific Config
|
||||||
ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"`
|
ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"`
|
||||||
|
|
||||||
|
// StoreCookies determines whether cookies are stored and included across requests.
|
||||||
|
StoreCookies bool `yaml:"store-cookies,omitempty"`
|
||||||
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +285,13 @@ func (c *Config) getHTTPClient() *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.StoreCookies {
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
logr.Fatalf("[client.getHTTPClient] Failed to initialize cookie jar: %v", err)
|
||||||
|
}
|
||||||
|
c.httpClient.Jar = jar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return c.httpClient
|
return c.httpClient
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ func TestConfig_getHTTPClient(t *testing.T) {
|
|||||||
if insecureClient.Timeout != defaultTimeout {
|
if insecureClient.Timeout != defaultTimeout {
|
||||||
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
|
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
|
||||||
}
|
}
|
||||||
|
if insecureClient.Jar != nil {
|
||||||
|
t.Error("expected Config.StoreCookies to default the HTTP client to not store cookies")
|
||||||
|
}
|
||||||
request, _ := http.NewRequest("GET", "", nil)
|
request, _ := http.NewRequest("GET", "", nil)
|
||||||
if err := insecureClient.CheckRedirect(request, nil); err != nil {
|
if err := insecureClient.CheckRedirect(request, nil); err != nil {
|
||||||
t.Error("expected Config.IgnoreRedirect set to false to cause the HTTP client's CheckRedirect to return nil")
|
t.Error("expected Config.IgnoreRedirect set to false to cause the HTTP client's CheckRedirect to return nil")
|
||||||
@@ -31,12 +34,24 @@ func TestConfig_getHTTPClient(t *testing.T) {
|
|||||||
if secureClient.Timeout != 5*time.Second {
|
if secureClient.Timeout != 5*time.Second {
|
||||||
t.Error("expected Config.Timeout to cause the HTTP client to have a timeout of 5s")
|
t.Error("expected Config.Timeout to cause the HTTP client to have a timeout of 5s")
|
||||||
}
|
}
|
||||||
|
if insecureClient.Jar != nil {
|
||||||
|
t.Error("expected Config.StoreCookies to default the HTTP client to not store cookies")
|
||||||
|
}
|
||||||
request, _ = http.NewRequest("GET", "", nil)
|
request, _ = http.NewRequest("GET", "", nil)
|
||||||
if err := secureClient.CheckRedirect(request, nil); err != http.ErrUseLastResponse {
|
if err := secureClient.CheckRedirect(request, nil); err != http.ErrUseLastResponse {
|
||||||
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
|
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfig_getHTTPClient_withCookieJar(t *testing.T) {
|
||||||
|
config := &Config{StoreCookies: true}
|
||||||
|
config.ValidateAndSetDefaults()
|
||||||
|
client := config.getHTTPClient()
|
||||||
|
if client.Jar == nil {
|
||||||
|
t.Error("expected Config.StoreCookies to cause the HTTP client to have a CookieJar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
|
func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
dnsResolver string
|
dnsResolver string
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
health "google.golang.org/grpc/health/grpc_health_v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PerformGRPCHealthCheck dials a gRPC target and performs the standard Health/Check RPC.
|
||||||
|
// Returns whether a connection was established, the serving status string, an error (if any), and the elapsed duration.
|
||||||
|
func PerformGRPCHealthCheck(address string, useTLS bool, cfg *Config) (bool, string, error, time.Duration) {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = GetDefaultConfig()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var opts []grpc.DialOption
|
||||||
|
// Transport credentials
|
||||||
|
if useTLS {
|
||||||
|
tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure}
|
||||||
|
if cfg.HasTLSConfig() && cfg.TLS.isValid() == nil {
|
||||||
|
tlsCfg = configureTLS(tlsCfg, *cfg.TLS)
|
||||||
|
}
|
||||||
|
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
|
||||||
|
} else {
|
||||||
|
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
}
|
||||||
|
// Custom dialer for DNS resolver or SSH tunnel
|
||||||
|
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||||
|
if cfg.ResolvedTunnel != nil {
|
||||||
|
return cfg.ResolvedTunnel.Dial("tcp", addr)
|
||||||
|
}
|
||||||
|
if cfg.HasCustomDNSResolver() {
|
||||||
|
resolverCfg, err := cfg.parseDNSResolver()
|
||||||
|
if err != nil {
|
||||||
|
// Shouldn't happen because already validated; log and fall back
|
||||||
|
logr.Errorf("[client.PerformGRPCHealthCheck] invalid DNS resolver: %v", err)
|
||||||
|
} else {
|
||||||
|
d := &net.Dialer{Resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
d := net.Dialer{}
|
||||||
|
return d.DialContext(ctx, resolverCfg.Protocol, resolverCfg.Host+":"+resolverCfg.Port)
|
||||||
|
}}}
|
||||||
|
return d.DialContext(ctx, "tcp", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, "tcp", addr)
|
||||||
|
}))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := grpc.DialContext(ctx, address, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err, time.Since(start)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client := health.NewHealthClient(conn)
|
||||||
|
resp, err := client.Check(ctx, &health.HealthCheckRequest{Service: ""})
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err, time.Since(start)
|
||||||
|
}
|
||||||
|
return true, resp.GetStatus().String(), nil, time.Since(start)
|
||||||
|
}
|
||||||
@@ -53,6 +53,10 @@ type Announcement struct {
|
|||||||
|
|
||||||
// Message is the user-facing text describing the announcement
|
// Message is the user-facing text describing the announcement
|
||||||
Message string `yaml:"message" json:"message"`
|
Message string `yaml:"message" json:"message"`
|
||||||
|
|
||||||
|
// Archived indicates whether the announcement should be displayed in the historical section
|
||||||
|
// instead of at the top of the status page
|
||||||
|
Archived bool `yaml:"archived,omitempty" json:"archived,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
|
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package announcement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnnouncement_ValidateAndSetDefaults(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
announcement *Announcement
|
||||||
|
expectedError error
|
||||||
|
expectedType string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid-announcement-with-all-fields",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: TypeWarning,
|
||||||
|
Message: "This is a test announcement",
|
||||||
|
Archived: false,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedType: TypeWarning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-announcement-with-archived-true",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: TypeOperational,
|
||||||
|
Message: "This is an archived announcement",
|
||||||
|
Archived: true,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedType: TypeOperational,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-announcement-with-empty-type-should-default-to-none",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Message: "This announcement has no type",
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedType: TypeNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-announcement-with-empty-message",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: TypeWarning,
|
||||||
|
Message: "",
|
||||||
|
},
|
||||||
|
expectedError: ErrEmptyMessage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-announcement-with-zero-timestamp",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: time.Time{},
|
||||||
|
Type: TypeWarning,
|
||||||
|
Message: "Test message",
|
||||||
|
},
|
||||||
|
expectedError: ErrMissingTimestamp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-announcement-with-invalid-type",
|
||||||
|
announcement: &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: "invalid-type",
|
||||||
|
Message: "Test message",
|
||||||
|
},
|
||||||
|
expectedError: ErrInvalidAnnouncementType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.announcement.ValidateAndSetDefaults()
|
||||||
|
if !errors.Is(err, scenario.expectedError) {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||||
|
}
|
||||||
|
if scenario.expectedError == nil && scenario.announcement.Type != scenario.expectedType {
|
||||||
|
t.Errorf("expected type %s, got %s", scenario.expectedType, scenario.announcement.Type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnouncement_ValidateAndSetDefaults_AllTypes(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
validTypes := []string{TypeOutage, TypeWarning, TypeInformation, TypeOperational, TypeNone}
|
||||||
|
for _, validType := range validTypes {
|
||||||
|
t.Run("type-"+validType, func(t *testing.T) {
|
||||||
|
announcement := &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: validType,
|
||||||
|
Message: "Test message",
|
||||||
|
}
|
||||||
|
if err := announcement.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Errorf("expected no error for type %s, got %v", validType, err)
|
||||||
|
}
|
||||||
|
if announcement.Type != validType {
|
||||||
|
t.Errorf("expected type %s, got %s", validType, announcement.Type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortByTimestamp(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
earlier := now.Add(-1 * time.Hour)
|
||||||
|
later := now.Add(1 * time.Hour)
|
||||||
|
announcements := []*Announcement{
|
||||||
|
{Timestamp: now, Message: "now"},
|
||||||
|
{Timestamp: later, Message: "later"},
|
||||||
|
{Timestamp: earlier, Message: "earlier"},
|
||||||
|
}
|
||||||
|
SortByTimestamp(announcements)
|
||||||
|
if announcements[0].Timestamp != later {
|
||||||
|
t.Error("expected first announcement to be the latest")
|
||||||
|
}
|
||||||
|
if announcements[1].Timestamp != now {
|
||||||
|
t.Error("expected second announcement to be the middle one")
|
||||||
|
}
|
||||||
|
if announcements[2].Timestamp != earlier {
|
||||||
|
t.Error("expected third announcement to be the earliest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortByTimestamp_WithArchivedField(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
earlier := now.Add(-1 * time.Hour)
|
||||||
|
later := now.Add(1 * time.Hour)
|
||||||
|
announcements := []*Announcement{
|
||||||
|
{Timestamp: now, Message: "now", Archived: false},
|
||||||
|
{Timestamp: later, Message: "later", Archived: true},
|
||||||
|
{Timestamp: earlier, Message: "earlier", Archived: false},
|
||||||
|
}
|
||||||
|
SortByTimestamp(announcements)
|
||||||
|
// Sorting should be by timestamp only, not affected by archived status
|
||||||
|
if announcements[0].Timestamp != later {
|
||||||
|
t.Error("expected first announcement to be the latest, regardless of archived status")
|
||||||
|
}
|
||||||
|
if !announcements[0].Archived {
|
||||||
|
t.Error("expected first announcement to be archived")
|
||||||
|
}
|
||||||
|
if announcements[1].Timestamp != now {
|
||||||
|
t.Error("expected second announcement to be the middle one")
|
||||||
|
}
|
||||||
|
if announcements[2].Timestamp != earlier {
|
||||||
|
t.Error("expected third announcement to be the earliest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAndSetDefaults_Slice(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
announcements []*Announcement
|
||||||
|
expectedError error
|
||||||
|
shouldValidate bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all-valid-announcements",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "First announcement"},
|
||||||
|
{Timestamp: now, Type: TypeOperational, Message: "Second announcement"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
shouldValidate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed-archived-announcements",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "Active announcement", Archived: false},
|
||||||
|
{Timestamp: now, Type: TypeOperational, Message: "Archived announcement", Archived: true},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
shouldValidate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one-invalid-announcement-in-slice",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
|
||||||
|
{Timestamp: now, Type: TypeOperational, Message: ""},
|
||||||
|
},
|
||||||
|
expectedError: ErrEmptyMessage,
|
||||||
|
shouldValidate: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "announcement-with-missing-timestamp",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
|
||||||
|
{Timestamp: time.Time{}, Type: TypeOperational, Message: "Invalid announcement"},
|
||||||
|
},
|
||||||
|
expectedError: ErrMissingTimestamp,
|
||||||
|
shouldValidate: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "announcement-with-invalid-type",
|
||||||
|
announcements: []*Announcement{
|
||||||
|
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
|
||||||
|
{Timestamp: now, Type: "bad-type", Message: "Invalid announcement"},
|
||||||
|
},
|
||||||
|
expectedError: ErrInvalidAnnouncementType,
|
||||||
|
shouldValidate: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := ValidateAndSetDefaults(scenario.announcements)
|
||||||
|
if !errors.Is(err, scenario.expectedError) {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnouncement_ArchivedFieldDefaults(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
announcement := &Announcement{
|
||||||
|
Timestamp: now,
|
||||||
|
Type: TypeWarning,
|
||||||
|
Message: "Test announcement",
|
||||||
|
// Archived not set, should default to false
|
||||||
|
}
|
||||||
|
if err := announcement.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
// Zero value for bool is false
|
||||||
|
if announcement.Archived {
|
||||||
|
t.Error("expected Archived to default to false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAndSetDefaults_EmptySlice(t *testing.T) {
|
||||||
|
announcements := []*Announcement{}
|
||||||
|
if err := ValidateAndSetDefaults(announcements); err != nil {
|
||||||
|
t.Errorf("expected no error for empty slice, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-54
@@ -6,6 +6,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -136,7 +137,7 @@ func (config *Config) GetUniqueExtraMetricLabels() []string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for label := range ep.ExtraLabels {
|
for label := range ep.ExtraLabels {
|
||||||
if contains(labels, label) {
|
if slices.Contains(labels, label) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
labels = append(labels, label)
|
labels = append(labels, label)
|
||||||
@@ -300,61 +301,61 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
|||||||
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
|
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
|
||||||
}
|
}
|
||||||
// XXX: End of v6.0.0 removals
|
// XXX: End of v6.0.0 removals
|
||||||
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
|
ValidateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
|
||||||
if err := validateSecurityConfig(config); err != nil {
|
if err := ValidateSecurityConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateEndpointsConfig(config); err != nil {
|
if err := ValidateEndpointsConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateWebConfig(config); err != nil {
|
if err := ValidateWebConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateUIConfig(config); err != nil {
|
if err := ValidateUIConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateMaintenanceConfig(config); err != nil {
|
if err := ValidateMaintenanceConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateStorageConfig(config); err != nil {
|
if err := ValidateStorageConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateRemoteConfig(config); err != nil {
|
if err := ValidateRemoteConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateConnectivityConfig(config); err != nil {
|
if err := ValidateConnectivityConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateTunnelingConfig(config); err != nil {
|
if err := ValidateTunnelingConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateAnnouncementsConfig(config); err != nil {
|
if err := ValidateAnnouncementsConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateSuitesConfig(config); err != nil {
|
if err := ValidateSuitesConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateUniqueKeys(config); err != nil {
|
if err := ValidateUniqueKeys(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
validateAndSetConcurrencyDefaults(config)
|
ValidateAndSetConcurrencyDefaults(config)
|
||||||
// Cross-config changes
|
// Cross-config changes
|
||||||
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
|
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateConnectivityConfig(config *Config) error {
|
func ValidateConnectivityConfig(config *Config) error {
|
||||||
if config.Connectivity != nil {
|
if config.Connectivity != nil {
|
||||||
return config.Connectivity.ValidateAndSetDefaults()
|
return config.Connectivity.ValidateAndSetDefaults()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateTunnelingConfig validates the tunneling configuration and resolves tunnel references
|
// ValidateTunnelingConfig validates the tunneling configuration and resolves tunnel references
|
||||||
// NOTE: This must be called after validateEndpointsConfig and validateSuitesConfig
|
// NOTE: This must be called after ValidateEndpointsConfig and ValidateSuitesConfig
|
||||||
// because it resolves tunnel references in endpoint and suite client configurations
|
// because it resolves tunnel references in endpoint and suite client configurations
|
||||||
func validateTunnelingConfig(config *Config) error {
|
func ValidateTunnelingConfig(config *Config) error {
|
||||||
if config.Tunneling != nil {
|
if config.Tunneling != nil {
|
||||||
if err := config.Tunneling.ValidateAndSetDefaults(); err != nil {
|
if err := config.Tunneling.ValidateAndSetDefaults(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -404,7 +405,7 @@ func resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAnnouncementsConfig(config *Config) error {
|
func ValidateAnnouncementsConfig(config *Config) error {
|
||||||
if config.Announcements != nil {
|
if config.Announcements != nil {
|
||||||
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
|
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -415,7 +416,7 @@ func validateAnnouncementsConfig(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRemoteConfig(config *Config) error {
|
func ValidateRemoteConfig(config *Config) error {
|
||||||
if config.Remote != nil {
|
if config.Remote != nil {
|
||||||
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -424,7 +425,7 @@ func validateRemoteConfig(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateStorageConfig(config *Config) error {
|
func ValidateStorageConfig(config *Config) error {
|
||||||
if config.Storage == nil {
|
if config.Storage == nil {
|
||||||
config.Storage = &storage.Config{
|
config.Storage = &storage.Config{
|
||||||
Type: storage.TypeMemory,
|
Type: storage.TypeMemory,
|
||||||
@@ -439,7 +440,7 @@ func validateStorageConfig(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateMaintenanceConfig(config *Config) error {
|
func ValidateMaintenanceConfig(config *Config) error {
|
||||||
if config.Maintenance == nil {
|
if config.Maintenance == nil {
|
||||||
config.Maintenance = maintenance.GetDefaultConfig()
|
config.Maintenance = maintenance.GetDefaultConfig()
|
||||||
} else {
|
} else {
|
||||||
@@ -450,7 +451,7 @@ func validateMaintenanceConfig(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateUIConfig(config *Config) error {
|
func ValidateUIConfig(config *Config) error {
|
||||||
if config.UI == nil {
|
if config.UI == nil {
|
||||||
config.UI = ui.GetDefaultConfig()
|
config.UI = ui.GetDefaultConfig()
|
||||||
} else {
|
} else {
|
||||||
@@ -461,7 +462,7 @@ func validateUIConfig(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateWebConfig(config *Config) error {
|
func ValidateWebConfig(config *Config) error {
|
||||||
if config.Web == nil {
|
if config.Web == nil {
|
||||||
config.Web = web.GetDefaultConfig()
|
config.Web = web.GetDefaultConfig()
|
||||||
} else {
|
} else {
|
||||||
@@ -470,11 +471,11 @@ func validateWebConfig(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateEndpointsConfig(config *Config) error {
|
func ValidateEndpointsConfig(config *Config) error {
|
||||||
duplicateValidationMap := make(map[string]bool)
|
duplicateValidationMap := make(map[string]bool)
|
||||||
// Validate endpoints
|
// Validate endpoints
|
||||||
for _, ep := range config.Endpoints {
|
for _, ep := range config.Endpoints {
|
||||||
logr.Debugf("[config.validateEndpointsConfig] Validating endpoint with key %s", ep.Key())
|
logr.Debugf("[config.ValidateEndpointsConfig] Validating endpoint with key %s", ep.Key())
|
||||||
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
|
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
|
||||||
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
|
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
|
||||||
} else {
|
} else {
|
||||||
@@ -484,10 +485,10 @@ func validateEndpointsConfig(config *Config) error {
|
|||||||
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
|
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logr.Infof("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
logr.Infof("[config.ValidateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||||
// Validate external endpoints
|
// Validate external endpoints
|
||||||
for _, ee := range config.ExternalEndpoints {
|
for _, ee := range config.ExternalEndpoints {
|
||||||
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
|
logr.Debugf("[config.ValidateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
|
||||||
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
|
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
|
||||||
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
|
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
|
||||||
} else {
|
} else {
|
||||||
@@ -497,13 +498,13 @@ func validateEndpointsConfig(config *Config) error {
|
|||||||
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
|
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logr.Infof("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
|
logr.Infof("[config.ValidateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSuitesConfig(config *Config) error {
|
func ValidateSuitesConfig(config *Config) error {
|
||||||
if config.Suites == nil || len(config.Suites) == 0 {
|
if config.Suites == nil || len(config.Suites) == 0 {
|
||||||
logr.Info("[config.validateSuitesConfig] No suites configured")
|
logr.Info("[config.ValidateSuitesConfig] No suites configured")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
suiteNames := make(map[string]bool)
|
suiteNames := make(map[string]bool)
|
||||||
@@ -532,11 +533,11 @@ func validateSuitesConfig(config *Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logr.Infof("[config.validateSuitesConfig] Validated %d suite(s)", len(config.Suites))
|
logr.Infof("[config.ValidateSuitesConfig] Validated %d suite(s)", len(config.Suites))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateUniqueKeys(config *Config) error {
|
func ValidateUniqueKeys(config *Config) error {
|
||||||
keyMap := make(map[string]string) // key -> description for error messages
|
keyMap := make(map[string]string) // key -> description for error messages
|
||||||
// Check all endpoints
|
// Check all endpoints
|
||||||
for _, ep := range config.Endpoints {
|
for _, ep := range config.Endpoints {
|
||||||
@@ -573,30 +574,28 @@ func validateUniqueKeys(config *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSecurityConfig(config *Config) error {
|
func ValidateSecurityConfig(config *Config) error {
|
||||||
if config.Security != nil {
|
if config.Security != nil {
|
||||||
if config.Security.ValidateAndSetDefaults() {
|
if !config.Security.ValidateAndSetDefaults() {
|
||||||
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
|
logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated")
|
||||||
} else {
|
|
||||||
// If there was an attempt to configure security, then it must mean that some confidential or private
|
|
||||||
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
|
|
||||||
return ErrInvalidSecurityConfig
|
return ErrInvalidSecurityConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateAlertingConfig validates the alerting configuration
|
// ValidateAlertingConfig validates the alerting configuration
|
||||||
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
|
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
|
||||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
|
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
|
||||||
// sets the default alert values when none are set.
|
// sets the default alert values when none are set.
|
||||||
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
|
func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
|
||||||
if alertingConfig == nil {
|
if alertingConfig == nil {
|
||||||
logr.Info("[config.validateAlertingConfig] Alerting is not configured")
|
logr.Info("[config.ValidateAlertingConfig] Alerting is not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
alertTypes := []alert.Type{
|
alertTypes := []alert.Type{
|
||||||
alert.TypeAWSSES,
|
alert.TypeAWSSES,
|
||||||
|
alert.TypeClickUp,
|
||||||
alert.TypeCustom,
|
alert.TypeCustom,
|
||||||
alert.TypeDatadog,
|
alert.TypeDatadog,
|
||||||
alert.TypeDiscord,
|
alert.TypeDiscord,
|
||||||
@@ -610,11 +609,11 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
|||||||
alert.TypeIFTTT,
|
alert.TypeIFTTT,
|
||||||
alert.TypeIlert,
|
alert.TypeIlert,
|
||||||
alert.TypeIncidentIO,
|
alert.TypeIncidentIO,
|
||||||
alert.TypeJetBrainsSpace,
|
|
||||||
alert.TypeLine,
|
alert.TypeLine,
|
||||||
alert.TypeMatrix,
|
alert.TypeMatrix,
|
||||||
alert.TypeMattermost,
|
alert.TypeMattermost,
|
||||||
alert.TypeMessagebird,
|
alert.TypeMessagebird,
|
||||||
|
alert.TypeN8N,
|
||||||
alert.TypeNewRelic,
|
alert.TypeNewRelic,
|
||||||
alert.TypeNtfy,
|
alert.TypeNtfy,
|
||||||
alert.TypeOpsgenie,
|
alert.TypeOpsgenie,
|
||||||
@@ -647,12 +646,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
|||||||
for _, ep := range endpoints {
|
for _, ep := range endpoints {
|
||||||
for alertIndex, endpointAlert := range ep.Alerts {
|
for alertIndex, endpointAlert := range ep.Alerts {
|
||||||
if alertType == endpointAlert.Type {
|
if alertType == endpointAlert.Type {
|
||||||
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
|
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
|
||||||
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||||
// Validate the endpoint alert's overrides, if applicable
|
// Validate the endpoint alert's overrides, if applicable
|
||||||
if len(endpointAlert.ProviderOverride) > 0 {
|
if len(endpointAlert.ProviderOverride) > 0 {
|
||||||
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
|
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
|
||||||
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
|
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,12 +660,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
|||||||
for _, ee := range externalEndpoints {
|
for _, ee := range externalEndpoints {
|
||||||
for alertIndex, endpointAlert := range ee.Alerts {
|
for alertIndex, endpointAlert := range ee.Alerts {
|
||||||
if alertType == endpointAlert.Type {
|
if alertType == endpointAlert.Type {
|
||||||
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
|
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
|
||||||
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||||
// Validate the endpoint alert's overrides, if applicable
|
// Validate the endpoint alert's overrides, if applicable
|
||||||
if len(endpointAlert.ProviderOverride) > 0 {
|
if len(endpointAlert.ProviderOverride) > 0 {
|
||||||
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
|
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
|
||||||
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
|
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -675,7 +674,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
|||||||
}
|
}
|
||||||
validProviders = append(validProviders, alertType)
|
validProviders = append(validProviders, alertType)
|
||||||
} else {
|
} else {
|
||||||
logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
|
logr.Warnf("[config.ValidateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
|
||||||
invalidProviders = append(invalidProviders, alertType)
|
invalidProviders = append(invalidProviders, alertType)
|
||||||
alertingConfig.SetAlertingProviderToNil(alertProvider)
|
alertingConfig.SetAlertingProviderToNil(alertProvider)
|
||||||
}
|
}
|
||||||
@@ -683,19 +682,19 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
|||||||
invalidProviders = append(invalidProviders, alertType)
|
invalidProviders = append(invalidProviders, alertType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logr.Infof("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
logr.Infof("[config.ValidateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAndSetConcurrencyDefaults(config *Config) {
|
func ValidateAndSetConcurrencyDefaults(config *Config) {
|
||||||
if config.DisableMonitoringLock {
|
if config.DisableMonitoringLock {
|
||||||
config.Concurrency = 0
|
config.Concurrency = 0
|
||||||
logr.Warn("WARNING: The 'disable-monitoring-lock' configuration has been deprecated and will be removed in v6.0.0")
|
logr.Warn("WARNING: The 'disable-monitoring-lock' configuration has been deprecated and will be removed in v6.0.0")
|
||||||
logr.Warn("WARNING: Please set 'concurrency: 0' instead")
|
logr.Warn("WARNING: Please set 'concurrency: 0' instead")
|
||||||
logr.Debug("[config.validateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
|
logr.Debug("[config.ValidateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
|
||||||
} else if config.Concurrency <= 0 && !config.DisableMonitoringLock {
|
} else if config.Concurrency <= 0 && !config.DisableMonitoringLock {
|
||||||
config.Concurrency = DefaultConcurrency
|
config.Concurrency = DefaultConcurrency
|
||||||
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
|
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
|
||||||
} else {
|
} else {
|
||||||
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
|
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-69
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
@@ -25,7 +27,6 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
@@ -775,10 +776,6 @@ alerting:
|
|||||||
to: "+1-234-567-8901"
|
to: "+1-234-567-8901"
|
||||||
teams:
|
teams:
|
||||||
webhook-url: "http://example.com"
|
webhook-url: "http://example.com"
|
||||||
jetbrainsspace:
|
|
||||||
project: "foo"
|
|
||||||
channel-id: "bar"
|
|
||||||
token: "baz"
|
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: website
|
- name: website
|
||||||
@@ -801,7 +798,6 @@ endpoints:
|
|||||||
success-threshold: 15
|
success-threshold: 15
|
||||||
- type: teams
|
- type: teams
|
||||||
- type: pushover
|
- type: pushover
|
||||||
- type: jetbrainsspace
|
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
`))
|
`))
|
||||||
@@ -828,8 +824,8 @@ endpoints:
|
|||||||
if config.Endpoints[0].Interval != 60*time.Second {
|
if config.Endpoints[0].Interval != 60*time.Second {
|
||||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||||
}
|
}
|
||||||
if len(config.Endpoints[0].Alerts) != 10 {
|
if len(config.Endpoints[0].Alerts) != 9 {
|
||||||
t.Fatal("There should've been 10 alerts configured")
|
t.Fatal("There should've been 9 alerts configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
||||||
@@ -936,12 +932,6 @@ endpoints:
|
|||||||
if !config.Endpoints[0].Alerts[8].IsEnabled() {
|
if !config.Endpoints[0].Alerts[8].IsEnabled() {
|
||||||
t.Error("The alert should've been enabled")
|
t.Error("The alert should've been enabled")
|
||||||
}
|
}
|
||||||
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
|
|
||||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
|
|
||||||
}
|
|
||||||
if !config.Endpoints[0].Alerts[9].IsEnabled() {
|
|
||||||
t.Error("The alert should've been enabled")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
|
||||||
@@ -1001,14 +991,6 @@ alerting:
|
|||||||
webhook-url: "http://example.com"
|
webhook-url: "http://example.com"
|
||||||
default-alert:
|
default-alert:
|
||||||
enabled: true
|
enabled: true
|
||||||
jetbrainsspace:
|
|
||||||
project: "foo"
|
|
||||||
channel-id: "bar"
|
|
||||||
token: "baz"
|
|
||||||
default-alert:
|
|
||||||
enabled: true
|
|
||||||
failure-threshold: 5
|
|
||||||
success-threshold: 3
|
|
||||||
email:
|
email:
|
||||||
from: "from@example.com"
|
from: "from@example.com"
|
||||||
username: "from@example.com"
|
username: "from@example.com"
|
||||||
@@ -1047,7 +1029,6 @@ endpoints:
|
|||||||
- type: twilio
|
- type: twilio
|
||||||
- type: teams
|
- type: teams
|
||||||
- type: pushover
|
- type: pushover
|
||||||
- type: jetbrainsspace
|
|
||||||
- type: email
|
- type: email
|
||||||
- type: gotify
|
- type: gotify
|
||||||
conditions:
|
conditions:
|
||||||
@@ -1169,22 +1150,6 @@ endpoints:
|
|||||||
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Alerting.JetBrainsSpace == nil || config.Alerting.JetBrainsSpace.Validate() != nil {
|
|
||||||
t.Fatal("JetBrainsSpace alerting config should've been valid")
|
|
||||||
}
|
|
||||||
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
|
|
||||||
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
|
|
||||||
}
|
|
||||||
if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" {
|
|
||||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project)
|
|
||||||
}
|
|
||||||
if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" {
|
|
||||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID)
|
|
||||||
}
|
|
||||||
if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" {
|
|
||||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.DefaultConfig.Token)
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
|
if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
|
||||||
t.Fatal("Email alerting config should've been valid")
|
t.Fatal("Email alerting config should've been valid")
|
||||||
}
|
}
|
||||||
@@ -1256,8 +1221,8 @@ endpoints:
|
|||||||
if config.Endpoints[0].Interval != 60*time.Second {
|
if config.Endpoints[0].Interval != 60*time.Second {
|
||||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||||
}
|
}
|
||||||
if len(config.Endpoints[0].Alerts) != 12 {
|
if len(config.Endpoints[0].Alerts) != 11 {
|
||||||
t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts))
|
t.Fatalf("There should've been 11 alerts configured, got %d", len(config.Endpoints[0].Alerts))
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
||||||
@@ -1374,21 +1339,21 @@ endpoints:
|
|||||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
|
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
|
if config.Endpoints[0].Alerts[9].Type != alert.TypeEmail {
|
||||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
|
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[9].Type)
|
||||||
}
|
}
|
||||||
if !config.Endpoints[0].Alerts[9].IsEnabled() {
|
if !config.Endpoints[0].Alerts[9].IsEnabled() {
|
||||||
t.Error("The alert should've been enabled")
|
t.Error("The alert should've been enabled")
|
||||||
}
|
}
|
||||||
if config.Endpoints[0].Alerts[9].FailureThreshold != 5 {
|
if config.Endpoints[0].Alerts[9].FailureThreshold != 3 {
|
||||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[9].FailureThreshold)
|
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].FailureThreshold)
|
||||||
}
|
}
|
||||||
if config.Endpoints[0].Alerts[9].SuccessThreshold != 3 {
|
if config.Endpoints[0].Alerts[9].SuccessThreshold != 2 {
|
||||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].SuccessThreshold)
|
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[9].SuccessThreshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[0].Alerts[10].Type != alert.TypeEmail {
|
if config.Endpoints[0].Alerts[10].Type != alert.TypeGotify {
|
||||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[10].Type)
|
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[10].Type)
|
||||||
}
|
}
|
||||||
if !config.Endpoints[0].Alerts[10].IsEnabled() {
|
if !config.Endpoints[0].Alerts[10].IsEnabled() {
|
||||||
t.Error("The alert should've been enabled")
|
t.Error("The alert should've been enabled")
|
||||||
@@ -1399,19 +1364,6 @@ endpoints:
|
|||||||
if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 {
|
if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 {
|
||||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold)
|
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[0].Alerts[11].Type != alert.TypeGotify {
|
|
||||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[11].Type)
|
|
||||||
}
|
|
||||||
if !config.Endpoints[0].Alerts[11].IsEnabled() {
|
|
||||||
t.Error("The alert should've been enabled")
|
|
||||||
}
|
|
||||||
if config.Endpoints[0].Alerts[11].FailureThreshold != 3 {
|
|
||||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[11].FailureThreshold)
|
|
||||||
}
|
|
||||||
if config.Endpoints[0].Alerts[11].SuccessThreshold != 2 {
|
|
||||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[11].SuccessThreshold)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
|
||||||
@@ -1904,6 +1856,7 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
|
|||||||
func TestGetAlertingProviderByAlertType(t *testing.T) {
|
func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||||
alertingConfig := &alerting.Config{
|
alertingConfig := &alerting.Config{
|
||||||
AWSSimpleEmailService: &awsses.AlertProvider{},
|
AWSSimpleEmailService: &awsses.AlertProvider{},
|
||||||
|
ClickUp: &clickup.AlertProvider{},
|
||||||
Custom: &custom.AlertProvider{},
|
Custom: &custom.AlertProvider{},
|
||||||
Datadog: &datadog.AlertProvider{},
|
Datadog: &datadog.AlertProvider{},
|
||||||
Discord: &discord.AlertProvider{},
|
Discord: &discord.AlertProvider{},
|
||||||
@@ -1917,7 +1870,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
|||||||
IFTTT: &ifttt.AlertProvider{},
|
IFTTT: &ifttt.AlertProvider{},
|
||||||
Ilert: &ilert.AlertProvider{},
|
Ilert: &ilert.AlertProvider{},
|
||||||
IncidentIO: &incidentio.AlertProvider{},
|
IncidentIO: &incidentio.AlertProvider{},
|
||||||
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
|
|
||||||
Line: &line.AlertProvider{},
|
Line: &line.AlertProvider{},
|
||||||
Matrix: &matrix.AlertProvider{},
|
Matrix: &matrix.AlertProvider{},
|
||||||
Mattermost: &mattermost.AlertProvider{},
|
Mattermost: &mattermost.AlertProvider{},
|
||||||
@@ -1949,6 +1901,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
|||||||
expected provider.AlertProvider
|
expected provider.AlertProvider
|
||||||
}{
|
}{
|
||||||
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
|
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
|
||||||
|
{alertType: alert.TypeClickUp, expected: alertingConfig.ClickUp},
|
||||||
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
|
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
|
||||||
{alertType: alert.TypeDatadog, expected: alertingConfig.Datadog},
|
{alertType: alert.TypeDatadog, expected: alertingConfig.Datadog},
|
||||||
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
|
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
|
||||||
@@ -1962,7 +1915,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
|||||||
{alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},
|
{alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},
|
||||||
{alertType: alert.TypeIlert, expected: alertingConfig.Ilert},
|
{alertType: alert.TypeIlert, expected: alertingConfig.Ilert},
|
||||||
{alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},
|
{alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},
|
||||||
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
|
|
||||||
{alertType: alert.TypeLine, expected: alertingConfig.Line},
|
{alertType: alert.TypeLine, expected: alertingConfig.Line},
|
||||||
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
|
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
|
||||||
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
|
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
|
||||||
@@ -2101,7 +2053,7 @@ func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
|
|||||||
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
|
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
|
||||||
}
|
}
|
||||||
for _, label := range tt.expected {
|
for _, label := range tt.expected {
|
||||||
if !contains(labels, label) {
|
if !slices.Contains(labels, label) {
|
||||||
t.Errorf("expected label %s to be present", label)
|
t.Errorf("expected label %s to be present", label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2596,19 +2548,19 @@ func TestValidateTunnelingConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
err := validateTunnelingConfig(tt.config)
|
err := ValidateTunnelingConfig(tt.config)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("validateTunnelingConfig() expected error but got none")
|
t.Error("ValidateTunnelingConfig() expected error but got none")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err.Error() != tt.errMsg {
|
if err.Error() != tt.errMsg {
|
||||||
t.Errorf("validateTunnelingConfig() error = %v, want %v", err.Error(), tt.errMsg)
|
t.Errorf("ValidateTunnelingConfig() error = %v, want %v", err.Error(), tt.errMsg)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("validateTunnelingConfig() unexpected error = %v", err)
|
t.Errorf("ValidateTunnelingConfig() unexpected error = %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type Condition string
|
|||||||
// Validate checks if the Condition is valid
|
// Validate checks if the Condition is valid
|
||||||
func (c Condition) Validate() error {
|
func (c Condition) Validate() error {
|
||||||
r := &Result{}
|
r := &Result{}
|
||||||
c.evaluate(r, false, nil)
|
c.evaluate(r, false, false, nil)
|
||||||
if len(r.Errors) != 0 {
|
if len(r.Errors) != 0 {
|
||||||
return errors.New(r.Errors[0])
|
return errors.New(r.Errors[0])
|
||||||
}
|
}
|
||||||
@@ -34,44 +34,50 @@ func (c Condition) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// evaluate the Condition with the Result and an optional context
|
// evaluate the Condition with the Result and an optional context
|
||||||
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, context *gontext.Gontext) bool {
|
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, resolveSuccessfulConditions bool, context *gontext.Gontext) bool {
|
||||||
condition := string(c)
|
condition := string(c)
|
||||||
success := false
|
success := false
|
||||||
conditionToDisplay := condition
|
conditionToDisplay := condition
|
||||||
|
shouldResolveCondition := func(success bool) bool {
|
||||||
|
if success {
|
||||||
|
return resolveSuccessfulConditions
|
||||||
|
}
|
||||||
|
return !dontResolveFailedConditions
|
||||||
|
}
|
||||||
if strings.Contains(condition, " == ") {
|
if strings.Contains(condition, " == ") {
|
||||||
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context)
|
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context)
|
||||||
success = isEqual(resolvedParameters[0], resolvedParameters[1])
|
success = isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||||
if !success && !dontResolveFailedConditions {
|
if shouldResolveCondition(success) {
|
||||||
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
|
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
|
||||||
}
|
}
|
||||||
} else if strings.Contains(condition, " != ") {
|
} else if strings.Contains(condition, " != ") {
|
||||||
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " != "), result, context)
|
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " != "), result, context)
|
||||||
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
|
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||||
if !success && !dontResolveFailedConditions {
|
if shouldResolveCondition(success) {
|
||||||
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
|
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
|
||||||
}
|
}
|
||||||
} else if strings.Contains(condition, " <= ") {
|
} else if strings.Contains(condition, " <= ") {
|
||||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " <= "), result, context)
|
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " <= "), result, context)
|
||||||
success = resolvedParameters[0] <= resolvedParameters[1]
|
success = resolvedParameters[0] <= resolvedParameters[1]
|
||||||
if !success && !dontResolveFailedConditions {
|
if shouldResolveCondition(success) {
|
||||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
|
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
|
||||||
}
|
}
|
||||||
} else if strings.Contains(condition, " >= ") {
|
} else if strings.Contains(condition, " >= ") {
|
||||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " >= "), result, context)
|
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " >= "), result, context)
|
||||||
success = resolvedParameters[0] >= resolvedParameters[1]
|
success = resolvedParameters[0] >= resolvedParameters[1]
|
||||||
if !success && !dontResolveFailedConditions {
|
if shouldResolveCondition(success) {
|
||||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
|
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
|
||||||
}
|
}
|
||||||
} else if strings.Contains(condition, " > ") {
|
} else if strings.Contains(condition, " > ") {
|
||||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " > "), result, context)
|
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " > "), result, context)
|
||||||
success = resolvedParameters[0] > resolvedParameters[1]
|
success = resolvedParameters[0] > resolvedParameters[1]
|
||||||
if !success && !dontResolveFailedConditions {
|
if shouldResolveCondition(success) {
|
||||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
|
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
|
||||||
}
|
}
|
||||||
} else if strings.Contains(condition, " < ") {
|
} else if strings.Contains(condition, " < ") {
|
||||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " < "), result, context)
|
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " < "), result, context)
|
||||||
success = resolvedParameters[0] < resolvedParameters[1]
|
success = resolvedParameters[0] < resolvedParameters[1]
|
||||||
if !success && !dontResolveFailedConditions {
|
if shouldResolveCondition(success) {
|
||||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
|
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -209,7 +215,44 @@ func sanitizeAndResolveNumericalWithContext(list []string, result *Result, conte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
|
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
|
||||||
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
|
resolvedStrings := make([]string, 2)
|
||||||
|
for i := range 2 {
|
||||||
|
// Check if the parameter is a certificate or domain expiration placeholder
|
||||||
|
if parameters[i] == CertificateExpirationPlaceholder || parameters[i] == DomainExpirationPlaceholder {
|
||||||
|
// Format as duration string (convert milliseconds back to duration)
|
||||||
|
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
|
||||||
|
resolvedStrings[i] = formatDuration(duration)
|
||||||
|
} else if _, err := time.ParseDuration(parameters[i]); err == nil {
|
||||||
|
// If the original parameter was a duration string (like "48h"), format the resolved value
|
||||||
|
// as a duration string too so it matches and doesn't show in parentheses
|
||||||
|
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
|
||||||
|
resolvedStrings[i] = formatDuration(duration)
|
||||||
|
} else {
|
||||||
|
// Format as integer
|
||||||
|
resolvedStrings[i] = strconv.Itoa(int(resolvedParameters[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prettify(parameters, resolvedStrings, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDuration formats a duration in a clean, human-readable way by removing unnecessary zero components.
|
||||||
|
// For example: 336h0m0s becomes 336h, 1h30m0s becomes 1h30m, but 1h0m15s stays as 1h0m15s.
|
||||||
|
// Truncates to whole seconds to avoid decimal values like 7353h5m54.67s.
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
// Truncate to whole seconds to avoid decimal seconds
|
||||||
|
d = d.Truncate(time.Second)
|
||||||
|
s := d.String()
|
||||||
|
// Special case: if duration is zero, return "0s"
|
||||||
|
if s == "0s" {
|
||||||
|
return "0s"
|
||||||
|
}
|
||||||
|
// Remove trailing "0s" if present
|
||||||
|
if strings.HasSuffix(s, "0s") {
|
||||||
|
s = strings.TrimSuffix(s, "0s")
|
||||||
|
// Remove trailing "0m" if present after removing "0s"
|
||||||
|
s = strings.TrimSuffix(s, "0m")
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
|
|||||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||||
for n := 0; n < b.N; n++ {
|
for n := 0; n < b.N; n++ {
|
||||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
|
|||||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||||
for n := 0; n < b.N; n++ {
|
for n := 0; n < b.N; n++ {
|
||||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
|
|||||||
condition := Condition("[BODY].name == john.doe")
|
condition := Condition("[BODY].name == john.doe")
|
||||||
for n := 0; n < b.N; n++ {
|
for n := 0; n < b.N; n++ {
|
||||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
|
|||||||
condition := Condition("[BODY].name == john.doe")
|
condition := Condition("[BODY].name == john.doe")
|
||||||
for n := 0; n < b.N; n++ {
|
for n := 0; n < b.N; n++ {
|
||||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
|
|||||||
condition := Condition("[BODY].user.name == bob.doe")
|
condition := Condition("[BODY].user.name == bob.doe")
|
||||||
for n := 0; n < b.N; n++ {
|
for n := 0; n < b.N; n++ {
|
||||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
|
|||||||
condition := Condition("len([BODY].name) == 8")
|
condition := Condition("len([BODY].name) == 8")
|
||||||
for n := 0; n < b.N; n++ {
|
for n := 0; n < b.N; n++ {
|
||||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
|
|||||||
condition := Condition("len([BODY].name) == 8")
|
condition := Condition("len([BODY].name) == 8")
|
||||||
for n := 0; n < b.N; n++ {
|
for n := 0; n < b.N; n++ {
|
||||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
|
|||||||
condition := Condition("[STATUS] == 200")
|
condition := Condition("[STATUS] == 200")
|
||||||
for n := 0; n < b.N; n++ {
|
for n := 0; n < b.N; n++ {
|
||||||
result := &Result{HTTPStatus: 200}
|
result := &Result{HTTPStatus: 200}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
|
|||||||
condition := Condition("[STATUS] == 200")
|
condition := Condition("[STATUS] == 200")
|
||||||
for n := 0; n < b.N; n++ {
|
for n := 0; n < b.N; n++ {
|
||||||
result := &Result{HTTPStatus: 400}
|
result := &Result{HTTPStatus: 400}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func TestCondition_evaluate(t *testing.T) {
|
|||||||
Condition Condition
|
Condition Condition
|
||||||
Result *Result
|
Result *Result
|
||||||
DontResolveFailedConditions bool
|
DontResolveFailedConditions bool
|
||||||
|
ResolveSuccessfulConditions bool
|
||||||
ExpectedSuccess bool
|
ExpectedSuccess bool
|
||||||
ExpectedOutput string
|
ExpectedOutput string
|
||||||
}{
|
}{
|
||||||
@@ -184,6 +185,14 @@ func TestCondition_evaluate(t *testing.T) {
|
|||||||
ExpectedSuccess: true,
|
ExpectedSuccess: true,
|
||||||
ExpectedOutput: "[BODY] == test",
|
ExpectedOutput: "[BODY] == test",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "body-resolved-on-success",
|
||||||
|
Condition: Condition("[BODY].status == UP"),
|
||||||
|
Result: &Result{Body: []byte("{\"status\":\"UP\"}")},
|
||||||
|
ResolveSuccessfulConditions: true,
|
||||||
|
ExpectedSuccess: true,
|
||||||
|
ExpectedOutput: "[BODY].status (UP) == UP",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "body-numerical-equal",
|
Name: "body-numerical-equal",
|
||||||
Condition: Condition("[BODY] == 123"),
|
Condition: Condition("[BODY] == 123"),
|
||||||
@@ -476,7 +485,7 @@ func TestCondition_evaluate(t *testing.T) {
|
|||||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
||||||
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
|
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
|
||||||
ExpectedSuccess: false,
|
ExpectedSuccess: false,
|
||||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000",
|
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (336h) > 2419200000",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "certificate-expiration-greater-than-duration",
|
Name: "certificate-expiration-greater-than-duration",
|
||||||
@@ -490,7 +499,7 @@ func TestCondition_evaluate(t *testing.T) {
|
|||||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
|
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
|
||||||
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
||||||
ExpectedSuccess: false,
|
ExpectedSuccess: false,
|
||||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
|
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (24h) > 48h",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "no-placeholders",
|
Name: "no-placeholders",
|
||||||
@@ -757,7 +766,7 @@ func TestCondition_evaluate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, nil)
|
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, scenario.ResolveSuccessfulConditions, nil)
|
||||||
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
|
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
|
||||||
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
|
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
|
||||||
}
|
}
|
||||||
@@ -771,7 +780,7 @@ func TestCondition_evaluate(t *testing.T) {
|
|||||||
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
||||||
condition := Condition("[STATUS] ? 201")
|
condition := Condition("[STATUS] ? 201")
|
||||||
result := &Result{HTTPStatus: 201}
|
result := &Result{HTTPStatus: 201}
|
||||||
condition.evaluate(result, false, nil)
|
condition.evaluate(result, false, false, nil)
|
||||||
if result.Success {
|
if result.Success {
|
||||||
t.Error("condition was invalid, result should've been a failure")
|
t.Error("condition was invalid, result should've been a failure")
|
||||||
}
|
}
|
||||||
@@ -791,7 +800,7 @@ func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {
|
|||||||
"max_response_time": 5000,
|
"max_response_time": 5000,
|
||||||
})
|
})
|
||||||
// Simulate suite endpoint evaluation with context
|
// Simulate suite endpoint evaluation with context
|
||||||
success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default)
|
success := condition.evaluate(result, false, false, ctx) // false = don't skip resolution (default)
|
||||||
if success {
|
if success {
|
||||||
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
|
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
|
||||||
}
|
}
|
||||||
@@ -814,7 +823,7 @@ func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {
|
|||||||
"expected_status": 200,
|
"expected_status": 200,
|
||||||
})
|
})
|
||||||
// Simulate suite endpoint evaluation with context
|
// Simulate suite endpoint evaluation with context
|
||||||
success := condition.evaluate(result, false, ctx)
|
success := condition.evaluate(result, false, false, ctx)
|
||||||
if !success {
|
if !success {
|
||||||
t.Error("Condition should have succeeded")
|
t.Error("Condition should have succeeded")
|
||||||
}
|
}
|
||||||
@@ -839,7 +848,7 @@ func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {
|
|||||||
"valid_key": 5000,
|
"valid_key": 5000,
|
||||||
})
|
})
|
||||||
// Simulate suite endpoint evaluation with context
|
// Simulate suite endpoint evaluation with context
|
||||||
success := condition.evaluate(result, false, ctx)
|
success := condition.evaluate(result, false, false, ctx)
|
||||||
if success {
|
if success {
|
||||||
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
|
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-13
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -50,6 +51,7 @@ const (
|
|||||||
TypeSTARTTLS Type = "STARTTLS"
|
TypeSTARTTLS Type = "STARTTLS"
|
||||||
TypeTLS Type = "TLS"
|
TypeTLS Type = "TLS"
|
||||||
TypeHTTP Type = "HTTP"
|
TypeHTTP Type = "HTTP"
|
||||||
|
TypeGRPC Type = "GRPC"
|
||||||
TypeWS Type = "WEBSOCKET"
|
TypeWS Type = "WEBSOCKET"
|
||||||
TypeSSH Type = "SSH"
|
TypeSSH Type = "SSH"
|
||||||
TypeUNKNOWN Type = "UNKNOWN"
|
TypeUNKNOWN Type = "UNKNOWN"
|
||||||
@@ -177,6 +179,8 @@ func (e *Endpoint) Type() Type {
|
|||||||
return TypeTLS
|
return TypeTLS
|
||||||
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
|
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
|
||||||
return TypeHTTP
|
return TypeHTTP
|
||||||
|
case strings.HasPrefix(e.URL, "grpc://") || strings.HasPrefix(e.URL, "grpcs://"):
|
||||||
|
return TypeGRPC
|
||||||
case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
|
case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
|
||||||
return TypeWS
|
return TypeWS
|
||||||
case strings.HasPrefix(e.URL, "ssh://"):
|
case strings.HasPrefix(e.URL, "ssh://"):
|
||||||
@@ -218,12 +222,12 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
|
|||||||
e.Headers = make(map[string]string)
|
e.Headers = make(map[string]string)
|
||||||
}
|
}
|
||||||
// Automatically add user agent header if there isn't one specified in the endpoint configuration
|
// Automatically add user agent header if there isn't one specified in the endpoint configuration
|
||||||
if _, userAgentHeaderExists := e.Headers[UserAgentHeader]; !userAgentHeaderExists {
|
if !hasHeader(e.Headers, UserAgentHeader) {
|
||||||
e.Headers[UserAgentHeader] = GatusUserAgent
|
e.Headers[UserAgentHeader] = GatusUserAgent
|
||||||
}
|
}
|
||||||
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
|
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
|
||||||
// and endpoint.GraphQL is set to true
|
// and endpoint.GraphQL is set to true
|
||||||
if _, contentTypeHeaderExists := e.Headers[ContentTypeHeader]; !contentTypeHeaderExists && e.GraphQL {
|
if !hasHeader(e.Headers, ContentTypeHeader) && e.GraphQL {
|
||||||
e.Headers[ContentTypeHeader] = "application/json"
|
e.Headers[ContentTypeHeader] = "application/json"
|
||||||
}
|
}
|
||||||
if len(e.Conditions) == 0 {
|
if len(e.Conditions) == 0 {
|
||||||
@@ -329,7 +333,7 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
|
|||||||
}
|
}
|
||||||
// Evaluate the conditions
|
// Evaluate the conditions
|
||||||
for _, condition := range processedEndpoint.Conditions {
|
for _, condition := range processedEndpoint.Conditions {
|
||||||
success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, context)
|
success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, processedEndpoint.UIConfig.ResolveSuccessfulConditions, context)
|
||||||
if !success {
|
if !success {
|
||||||
result.Success = false
|
result.Success = false
|
||||||
}
|
}
|
||||||
@@ -353,6 +357,9 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
|
|||||||
}
|
}
|
||||||
result.port = ""
|
result.port = ""
|
||||||
}
|
}
|
||||||
|
if processedEndpoint.UIConfig.HideErrors {
|
||||||
|
result.Errors = nil
|
||||||
|
}
|
||||||
if processedEndpoint.UIConfig.HideConditions {
|
if processedEndpoint.UIConfig.HideConditions {
|
||||||
result.ConditionResults = nil
|
result.ConditionResults = nil
|
||||||
}
|
}
|
||||||
@@ -483,12 +490,10 @@ func (e *Endpoint) call(result *Result) {
|
|||||||
} else if endpointType == TypeWS {
|
} else if endpointType == TypeWS {
|
||||||
wsHeaders := map[string]string{}
|
wsHeaders := map[string]string{}
|
||||||
if e.Headers != nil {
|
if e.Headers != nil {
|
||||||
for k, v := range e.Headers {
|
maps.Copy(wsHeaders, e.Headers)
|
||||||
wsHeaders[k] = v
|
|
||||||
}
|
}
|
||||||
}
|
if !hasHeader(wsHeaders, UserAgentHeader) {
|
||||||
if _, exists := wsHeaders["User-Agent"]; !exists {
|
wsHeaders[UserAgentHeader] = GatusUserAgent
|
||||||
wsHeaders["User-Agent"] = GatusUserAgent
|
|
||||||
}
|
}
|
||||||
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)
|
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -497,8 +502,8 @@ func (e *Endpoint) call(result *Result) {
|
|||||||
}
|
}
|
||||||
result.Duration = time.Since(startTime)
|
result.Duration = time.Since(startTime)
|
||||||
} else if endpointType == TypeSSH {
|
} else if endpointType == TypeSSH {
|
||||||
// If there's no username/password specified, attempt to validate just the SSH banner
|
// If there's no username, password or private key specified, attempt to validate just the SSH banner
|
||||||
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
|
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
|
||||||
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.AddError(err.Error())
|
result.AddError(err.Error())
|
||||||
@@ -509,17 +514,35 @@ func (e *Endpoint) call(result *Result) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var cli *ssh.Client
|
var cli *ssh.Client
|
||||||
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
|
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.AddError(err.Error())
|
result.AddError(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
|
var output []byte
|
||||||
|
result.Success, result.HTTPStatus, output, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.AddError(err.Error())
|
result.AddError(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Only store the output in result.Body if there's a condition that uses the BodyPlaceholder
|
||||||
|
if e.needsToReadBody() {
|
||||||
|
result.Body = output
|
||||||
|
}
|
||||||
result.Duration = time.Since(startTime)
|
result.Duration = time.Since(startTime)
|
||||||
|
} else if endpointType == TypeGRPC {
|
||||||
|
useTLS := strings.HasPrefix(e.URL, "grpcs://")
|
||||||
|
address := strings.TrimPrefix(strings.TrimPrefix(e.URL, "grpcs://"), "grpc://")
|
||||||
|
connected, status, err, duration := client.PerformGRPCHealthCheck(address, useTLS, e.ClientConfig)
|
||||||
|
if err != nil {
|
||||||
|
result.AddError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.Connected = connected
|
||||||
|
result.Duration = duration
|
||||||
|
if e.needsToReadBody() {
|
||||||
|
result.Body = []byte(fmt.Sprintf("{\"status\":\"%s\"}", status))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
|
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
|
||||||
result.Duration = time.Since(startTime)
|
result.Duration = time.Since(startTime)
|
||||||
@@ -558,7 +581,7 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
|
|||||||
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
|
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
|
||||||
for k, v := range e.Headers {
|
for k, v := range e.Headers {
|
||||||
request.Header.Set(k, v)
|
request.Header.Set(k, v)
|
||||||
if k == HostHeader {
|
if strings.EqualFold(k, HostHeader) {
|
||||||
request.Host = v
|
request.Host = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,3 +625,13 @@ func (e *Endpoint) needsToRetrieveIP() bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasHeader checks if a header exists in the map using a case-insensitive lookup
|
||||||
|
func hasHeader(headers map[string]string, name string) bool {
|
||||||
|
for k := range headers {
|
||||||
|
if strings.EqualFold(k, name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestHasHeader(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
headers map[string]string
|
||||||
|
lookup string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{name: "exact-match", headers: map[string]string{"User-Agent": "test"}, lookup: "User-Agent", expected: true},
|
||||||
|
{name: "lowercase-lookup", headers: map[string]string{"User-Agent": "test"}, lookup: "user-agent", expected: true},
|
||||||
|
{name: "uppercase-lookup", headers: map[string]string{"user-agent": "test"}, lookup: "USER-AGENT", expected: true},
|
||||||
|
{name: "mixed-case", headers: map[string]string{"UsEr-AgEnT": "test"}, lookup: "uSeR-aGeNt", expected: true},
|
||||||
|
{name: "not-found", headers: map[string]string{"Content-Type": "test"}, lookup: "User-Agent", expected: false},
|
||||||
|
{name: "empty-headers", headers: map[string]string{}, lookup: "User-Agent", expected: false},
|
||||||
|
{name: "nil-headers", headers: nil, lookup: "User-Agent", expected: false},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
if result := hasHeader(scenario.headers, scenario.lookup); result != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEndpoint(t *testing.T) {
|
func TestEndpoint(t *testing.T) {
|
||||||
defer client.InjectHTTPClient(nil)
|
defer client.InjectHTTPClient(nil)
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
@@ -268,6 +292,31 @@ func TestEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEndpoint_ResolveSuccessfulConditions(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
endpoint := Endpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
URL: "https://example.com/health",
|
||||||
|
Conditions: []Condition{"[BODY].status == UP"},
|
||||||
|
UIConfig: &ui.Config{ResolveSuccessfulConditions: true},
|
||||||
|
}
|
||||||
|
mockResponse := test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status":"UP"}`))}
|
||||||
|
})
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: mockResponse})
|
||||||
|
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
|
||||||
|
}
|
||||||
|
result := endpoint.EvaluateHealth()
|
||||||
|
if len(result.ConditionResults) != 1 {
|
||||||
|
t.Fatalf("expected 1 condition result, got %d", len(result.ConditionResults))
|
||||||
|
}
|
||||||
|
expectedCondition := "[BODY].status (UP) == UP"
|
||||||
|
if result.ConditionResults[0].Condition != expectedCondition {
|
||||||
|
t.Errorf("expected condition to be '%s', got '%s'", expectedCondition, result.ConditionResults[0].Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEndpoint_IsEnabled(t *testing.T) {
|
func TestEndpoint_IsEnabled(t *testing.T) {
|
||||||
if !(&Endpoint{Enabled: nil}).IsEnabled() {
|
if !(&Endpoint{Enabled: nil}).IsEnabled() {
|
||||||
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
|
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
|
||||||
@@ -511,26 +560,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
|
privateKey string
|
||||||
expectedErr error
|
expectedErr error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "fail when has no user",
|
name: "fail when has no user but has password",
|
||||||
username: "",
|
username: "",
|
||||||
password: "password",
|
password: "password",
|
||||||
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fail when has no password",
|
name: "fail when has no user but has private key",
|
||||||
username: "username",
|
username: "",
|
||||||
password: "",
|
privateKey: "-----BEGIN",
|
||||||
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
|
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "success when all fields are set",
|
name: "fail when has no password or private key",
|
||||||
|
username: "username",
|
||||||
|
password: "",
|
||||||
|
privateKey: "",
|
||||||
|
expectedErr: ssh.ErrEndpointWithoutSSHAuth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "success when username and password are set",
|
||||||
username: "username",
|
username: "username",
|
||||||
password: "password",
|
password: "password",
|
||||||
expectedErr: nil,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "success when username and private key are set",
|
||||||
|
username: "username",
|
||||||
|
privateKey: "-----BEGIN",
|
||||||
|
expectedErr: nil,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
@@ -541,6 +604,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
|||||||
SSHConfig: &ssh.Config{
|
SSHConfig: &ssh.Config{
|
||||||
Username: scenario.username,
|
Username: scenario.username,
|
||||||
Password: scenario.password,
|
Password: scenario.password,
|
||||||
|
PrivateKey: scenario.privateKey,
|
||||||
},
|
},
|
||||||
Conditions: []Condition{Condition("[STATUS] == 0")},
|
Conditions: []Condition{Condition("[STATUS] == 0")},
|
||||||
}
|
}
|
||||||
@@ -682,6 +746,76 @@ func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEndpoint_buildHTTPRequestWithLowercaseUserAgent(t *testing.T) {
|
||||||
|
condition := Condition("[STATUS] == 200")
|
||||||
|
endpoint := Endpoint{
|
||||||
|
Name: "website-health",
|
||||||
|
URL: "https://twin.sh/health",
|
||||||
|
Conditions: []Condition{condition},
|
||||||
|
Headers: map[string]string{
|
||||||
|
"user-agent": "CustomAgent/1.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := endpoint.ValidateAndSetDefaults()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("did not expect an error, got", err)
|
||||||
|
}
|
||||||
|
if _, exists := endpoint.Headers[UserAgentHeader]; exists {
|
||||||
|
t.Error("User-Agent header should not have been added since user-agent was already specified")
|
||||||
|
}
|
||||||
|
request := endpoint.buildHTTPRequest()
|
||||||
|
if userAgent := request.Header.Get("User-Agent"); userAgent != "CustomAgent/1.0" {
|
||||||
|
t.Errorf("request.Header.Get(User-Agent) should've been CustomAgent/1.0, but was %s", userAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndpoint_buildHTTPRequestWithLowercaseContentType(t *testing.T) {
|
||||||
|
condition := Condition("[STATUS] == 200")
|
||||||
|
endpoint := Endpoint{
|
||||||
|
Name: "website-graphql",
|
||||||
|
URL: "https://twin.sh/graphql",
|
||||||
|
Method: "POST",
|
||||||
|
Conditions: []Condition{condition},
|
||||||
|
GraphQL: true,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"content-type": "application/graphql",
|
||||||
|
},
|
||||||
|
Body: `{ users { id } }`,
|
||||||
|
}
|
||||||
|
err := endpoint.ValidateAndSetDefaults()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("did not expect an error, got", err)
|
||||||
|
}
|
||||||
|
if _, exists := endpoint.Headers[ContentTypeHeader]; exists {
|
||||||
|
t.Error("Content-Type header should not have been added since content-type was already specified")
|
||||||
|
}
|
||||||
|
request := endpoint.buildHTTPRequest()
|
||||||
|
if contentType := request.Header.Get("Content-Type"); contentType != "application/graphql" {
|
||||||
|
t.Errorf("request.Header.Get(Content-Type) should've been application/graphql, but was %s", contentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndpoint_buildHTTPRequestWithLowercaseHostHeader(t *testing.T) {
|
||||||
|
condition := Condition("[STATUS] == 200")
|
||||||
|
endpoint := Endpoint{
|
||||||
|
Name: "website-health",
|
||||||
|
URL: "https://twin.sh/health",
|
||||||
|
Method: "POST",
|
||||||
|
Conditions: []Condition{condition},
|
||||||
|
Headers: map[string]string{
|
||||||
|
"host": "example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := endpoint.ValidateAndSetDefaults()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("did not expect an error, got", err)
|
||||||
|
}
|
||||||
|
request := endpoint.buildHTTPRequest()
|
||||||
|
if request.Host != "example.com" {
|
||||||
|
t.Error("request.Host should've been example.com, but was", request.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
|
func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
|
||||||
condition := Condition("[STATUS] == 200")
|
condition := Condition("[STATUS] == 200")
|
||||||
endpoint := Endpoint{
|
endpoint := Endpoint{
|
||||||
@@ -1448,3 +1582,168 @@ func TestEndpoint_preprocessWithContext(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEndpoint_HideUIFeatures(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
endpoint Endpoint
|
||||||
|
mockResponse test.MockRoundTripper
|
||||||
|
checkHostname bool
|
||||||
|
expectHostname string
|
||||||
|
checkErrors bool
|
||||||
|
expectErrors bool
|
||||||
|
checkConditions bool
|
||||||
|
expectConditions bool
|
||||||
|
checkErrorContent string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "hide-conditions",
|
||||||
|
endpoint: Endpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
URL: "https://example.com/health",
|
||||||
|
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
|
||||||
|
UIConfig: &ui.Config{HideConditions: true},
|
||||||
|
},
|
||||||
|
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`))}
|
||||||
|
}),
|
||||||
|
checkConditions: true,
|
||||||
|
expectConditions: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hide-hostname",
|
||||||
|
endpoint: Endpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
URL: "https://example.com/health",
|
||||||
|
Conditions: []Condition{"[STATUS] == 200"},
|
||||||
|
UIConfig: &ui.Config{HideHostname: true},
|
||||||
|
},
|
||||||
|
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
checkHostname: true,
|
||||||
|
expectHostname: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hide-url-in-errors",
|
||||||
|
endpoint: Endpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
URL: "https://example.com/health",
|
||||||
|
Conditions: []Condition{"[CONNECTED] == true"},
|
||||||
|
UIConfig: &ui.Config{HideURL: true},
|
||||||
|
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||||
|
},
|
||||||
|
mockResponse: nil,
|
||||||
|
checkErrors: true,
|
||||||
|
expectErrors: true,
|
||||||
|
checkErrorContent: "<redacted>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hide-port-in-errors",
|
||||||
|
endpoint: Endpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
URL: "https://example.com:9999/health",
|
||||||
|
Conditions: []Condition{"[CONNECTED] == true"},
|
||||||
|
UIConfig: &ui.Config{HidePort: true},
|
||||||
|
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||||
|
},
|
||||||
|
mockResponse: nil,
|
||||||
|
checkErrors: true,
|
||||||
|
expectErrors: true,
|
||||||
|
checkErrorContent: "<redacted>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hide-errors",
|
||||||
|
endpoint: Endpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
URL: "https://example.com/health",
|
||||||
|
Conditions: []Condition{"[CONNECTED] == true"},
|
||||||
|
UIConfig: &ui.Config{HideErrors: true},
|
||||||
|
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||||
|
},
|
||||||
|
mockResponse: nil,
|
||||||
|
checkErrors: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dont-resolve-failed-conditions",
|
||||||
|
endpoint: Endpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
URL: "https://example.com/health",
|
||||||
|
Conditions: []Condition{"[STATUS] == 200"},
|
||||||
|
UIConfig: &ui.Config{DontResolveFailedConditions: true},
|
||||||
|
},
|
||||||
|
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
checkConditions: true,
|
||||||
|
expectConditions: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple-hide-features",
|
||||||
|
endpoint: Endpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
URL: "https://example.com/health",
|
||||||
|
Conditions: []Condition{"[STATUS] == 200"},
|
||||||
|
UIConfig: &ui.Config{HideConditions: true, HideHostname: true, HideErrors: true},
|
||||||
|
},
|
||||||
|
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
checkConditions: true,
|
||||||
|
expectConditions: false,
|
||||||
|
checkHostname: true,
|
||||||
|
expectHostname: "",
|
||||||
|
checkErrors: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.mockResponse != nil {
|
||||||
|
mockClient := &http.Client{Transport: tt.mockResponse}
|
||||||
|
if tt.endpoint.ClientConfig != nil && tt.endpoint.ClientConfig.Timeout > 0 {
|
||||||
|
mockClient.Timeout = tt.endpoint.ClientConfig.Timeout
|
||||||
|
}
|
||||||
|
client.InjectHTTPClient(mockClient)
|
||||||
|
} else {
|
||||||
|
client.InjectHTTPClient(nil)
|
||||||
|
}
|
||||||
|
err := tt.endpoint.ValidateAndSetDefaults()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
|
||||||
|
}
|
||||||
|
result := tt.endpoint.EvaluateHealth()
|
||||||
|
if tt.checkHostname {
|
||||||
|
if result.Hostname != tt.expectHostname {
|
||||||
|
t.Errorf("Expected hostname '%s', got '%s'", tt.expectHostname, result.Hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tt.checkErrors {
|
||||||
|
hasErrors := len(result.Errors) > 0
|
||||||
|
if hasErrors != tt.expectErrors {
|
||||||
|
t.Errorf("Expected errors=%v, got errors=%v (actual errors: %v)", tt.expectErrors, hasErrors, result.Errors)
|
||||||
|
}
|
||||||
|
if tt.checkErrorContent != "" && len(result.Errors) > 0 {
|
||||||
|
found := false
|
||||||
|
for _, err := range result.Errors {
|
||||||
|
if strings.Contains(err, tt.checkErrorContent) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected error to contain '%s', but got: %v", tt.checkErrorContent, result.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tt.checkConditions {
|
||||||
|
hasConditions := len(result.ConditionResults) > 0
|
||||||
|
if hasConditions != tt.expectConditions {
|
||||||
|
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package endpoint
|
package endpoint
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,11 +67,7 @@ type Result struct {
|
|||||||
// AddError adds an error to the result's list of errors.
|
// AddError adds an error to the result's list of errors.
|
||||||
// It also ensures that there are no duplicates.
|
// It also ensures that there are no duplicates.
|
||||||
func (r *Result) AddError(error string) {
|
func (r *Result) AddError(error string) {
|
||||||
for _, resultError := range r.Errors {
|
if !slices.Contains(r.Errors, error) {
|
||||||
if resultError == error {
|
|
||||||
// If the error already exists, don't add it
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.Errors = append(r.Errors, error+"")
|
r.Errors = append(r.Errors, error+"")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,26 +8,29 @@ var (
|
|||||||
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
|
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
|
||||||
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
|
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
|
||||||
|
|
||||||
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
|
// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
|
||||||
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
|
ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Username string `yaml:"username,omitempty"`
|
Username string `yaml:"username,omitempty"`
|
||||||
Password string `yaml:"password,omitempty"`
|
Password string `yaml:"password,omitempty"`
|
||||||
|
PrivateKey string `yaml:"private-key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the SSH configuration
|
// Validate the SSH configuration
|
||||||
func (cfg *Config) Validate() error {
|
func (cfg *Config) Validate() error {
|
||||||
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
|
// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid
|
||||||
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
|
if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// If any authentication method is provided (password or private key), a username is required
|
||||||
if len(cfg.Username) == 0 {
|
if len(cfg.Username) == 0 {
|
||||||
return ErrEndpointWithoutSSHUsername
|
return ErrEndpointWithoutSSHUsername
|
||||||
}
|
}
|
||||||
if len(cfg.Password) == 0 {
|
// If a username is provided, require at least a password or a private key
|
||||||
return ErrEndpointWithoutSSHPassword
|
if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
|
||||||
|
return ErrEndpointWithoutSSHAuth
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSSH_validate(t *testing.T) {
|
func TestSSH_validatePasswordCfg(t *testing.T) {
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
t.Error("didn't expect an error")
|
t.Error("didn't expect an error")
|
||||||
@@ -13,11 +13,26 @@ func TestSSH_validate(t *testing.T) {
|
|||||||
cfg.Username = "username"
|
cfg.Username = "username"
|
||||||
if err := cfg.Validate(); err == nil {
|
if err := cfg.Validate(); err == nil {
|
||||||
t.Error("expected an error")
|
t.Error("expected an error")
|
||||||
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
|
} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
|
||||||
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
|
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
|
||||||
}
|
}
|
||||||
cfg.Password = "password"
|
cfg.Password = "password"
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
t.Errorf("expected no error, got '%v'", err)
|
t.Errorf("expected no error, got '%v'", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSSH_validatePrivateKeyCfg(t *testing.T) {
|
||||||
|
t.Run("fail when username missing but private key provided", func(t *testing.T) {
|
||||||
|
cfg := &Config{PrivateKey: "-----BEGIN"}
|
||||||
|
if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {
|
||||||
|
t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("success when username with private key", func(t *testing.T) {
|
||||||
|
cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,9 +16,15 @@ type Config struct {
|
|||||||
// HidePort whether to hide the port in the Result
|
// HidePort whether to hide the port in the Result
|
||||||
HidePort bool `yaml:"hide-port"`
|
HidePort bool `yaml:"hide-port"`
|
||||||
|
|
||||||
|
// HideErrors whether to hide the errors in the Result
|
||||||
|
HideErrors bool `yaml:"hide-errors"`
|
||||||
|
|
||||||
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
|
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
|
||||||
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
|
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
|
||||||
|
|
||||||
|
// ResolveSuccessfulConditions whether to resolve successful conditions in the Result for display in the UI
|
||||||
|
ResolveSuccessfulConditions bool `yaml:"resolve-successful-conditions"`
|
||||||
|
|
||||||
// Badge is the configuration for the badges generated
|
// Badge is the configuration for the badges generated
|
||||||
Badge *Badge `yaml:"badge"`
|
Badge *Badge `yaml:"badge"`
|
||||||
}
|
}
|
||||||
@@ -58,7 +64,9 @@ func GetDefaultConfig() *Config {
|
|||||||
HideHostname: false,
|
HideHostname: false,
|
||||||
HideURL: false,
|
HideURL: false,
|
||||||
HidePort: false,
|
HidePort: false,
|
||||||
|
HideErrors: false,
|
||||||
DontResolveFailedConditions: false,
|
DontResolveFailedConditions: false,
|
||||||
|
ResolveSuccessfulConditions: false,
|
||||||
HideConditions: false,
|
HideConditions: false,
|
||||||
Badge: &Badge{
|
Badge: &Badge{
|
||||||
ResponseTime: &ResponseTime{
|
ResponseTime: &ResponseTime{
|
||||||
|
|||||||
@@ -340,9 +340,9 @@ func TestGontext_ConcurrentAccess(t *testing.T) {
|
|||||||
done := make(chan bool, 10)
|
done := make(chan bool, 10)
|
||||||
|
|
||||||
// Start 5 goroutines that read values
|
// Start 5 goroutines that read values
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
for j := 0; j < 100; j++ {
|
for range 100 {
|
||||||
_, err := ctx.Get("counter")
|
_, err := ctx.Get("counter")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Reader %d error: %v", id, err)
|
t.Errorf("Reader %d error: %v", id, err)
|
||||||
@@ -353,9 +353,9 @@ func TestGontext_ConcurrentAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start 5 goroutines that write values
|
// Start 5 goroutines that write values
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
for j := 0; j < 100; j++ {
|
for j := range 100 {
|
||||||
err := ctx.Set("counter", id*1000+j)
|
err := ctx.Set("counter", id*1000+j)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Writer %d error: %v", id, err)
|
t.Errorf("Writer %d error: %v", id, err)
|
||||||
@@ -366,7 +366,7 @@ func TestGontext_ConcurrentAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all goroutines to complete
|
// Wait for all goroutines to complete
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ func sanitize(s string) string {
|
|||||||
s = strings.ReplaceAll(s, ",", "-")
|
s = strings.ReplaceAll(s, ",", "-")
|
||||||
s = strings.ReplaceAll(s, " ", "-")
|
s = strings.ReplaceAll(s, " ", "-")
|
||||||
s = strings.ReplaceAll(s, "#", "-")
|
s = strings.ReplaceAll(s, "#", "-")
|
||||||
|
s = strings.ReplaceAll(s, "+", "-")
|
||||||
|
s = strings.ReplaceAll(s, "&", "-")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,21 @@ func TestConvertGroupAndNameToKey(t *testing.T) {
|
|||||||
Name: "name",
|
Name: "name",
|
||||||
ExpectedOutput: "_name",
|
ExpectedOutput: "_name",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
GroupName: "API (v1)",
|
||||||
|
Name: "endpoint",
|
||||||
|
ExpectedOutput: "api-(v1)_endpoint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
GroupName: "website (admin)",
|
||||||
|
Name: "test",
|
||||||
|
ExpectedOutput: "website-(admin)_test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
GroupName: "search",
|
||||||
|
Name: "query&filter",
|
||||||
|
ExpectedOutput: "search_query-filter",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
|
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package maintenance
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,16 +33,16 @@ var (
|
|||||||
// Uses UTC by default.
|
// Uses UTC by default.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
|
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)
|
Start string `yaml:"start,omitempty"` // Time at which the maintenance period starts (e.g. 23:00)
|
||||||
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
|
Duration time.Duration `yaml:"duration,omitempty"` // Duration of the maintenance period (e.g. 4h)
|
||||||
Timezone string `yaml:"timezone"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
|
Timezone string `yaml:"timezone,omitempty"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
|
||||||
|
|
||||||
// Every is a list of days of the week during which maintenance period applies.
|
// Every is a list of days of the week during which maintenance period applies.
|
||||||
// See longDayNames for list of valid values.
|
// See longDayNames for list of valid values.
|
||||||
// Every day if empty.
|
// Every day if empty.
|
||||||
Every []string `yaml:"every"`
|
Every []string `yaml:"every,omitempty"`
|
||||||
|
|
||||||
TimezoneLocation *time.Location // Timezone in location format which the maintenance period is configured
|
timezoneLocation *time.Location
|
||||||
durationToStartFromMidnight time.Duration
|
durationToStartFromMidnight time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +71,7 @@ func (c *Config) ValidateAndSetDefaults() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, day := range c.Every {
|
for _, day := range c.Every {
|
||||||
isDayValid := false
|
isDayValid := slices.Contains(longDayNames, day)
|
||||||
for _, longDayName := range longDayNames {
|
|
||||||
if day == longDayName {
|
|
||||||
isDayValid = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !isDayValid {
|
if !isDayValid {
|
||||||
return errInvalidDayName
|
return errInvalidDayName
|
||||||
}
|
}
|
||||||
@@ -90,13 +85,13 @@ func (c *Config) ValidateAndSetDefaults() error {
|
|||||||
return errInvalidMaintenanceDuration
|
return errInvalidMaintenanceDuration
|
||||||
}
|
}
|
||||||
if c.Timezone != "" {
|
if c.Timezone != "" {
|
||||||
c.TimezoneLocation, err = time.LoadLocation(c.Timezone)
|
c.timezoneLocation, err = time.LoadLocation(c.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %w", errInvalidTimezone, err)
|
return fmt.Errorf("%w: %w", errInvalidTimezone, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
c.Timezone = "UTC"
|
c.Timezone = "UTC"
|
||||||
c.TimezoneLocation = time.UTC
|
c.timezoneLocation = time.UTC
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -107,8 +102,8 @@ func (c *Config) IsUnderMaintenance() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if c.TimezoneLocation != nil {
|
if c.timezoneLocation != nil {
|
||||||
now = now.In(c.TimezoneLocation)
|
now = now.In(c.timezoneLocation)
|
||||||
}
|
}
|
||||||
adjustedDate := now.Day()
|
adjustedDate := now.Day()
|
||||||
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
|
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
|
||||||
@@ -118,7 +113,7 @@ func (c *Config) IsUnderMaintenance() bool {
|
|||||||
// Set to midnight prior to adding duration
|
// Set to midnight prior to adding duration
|
||||||
dayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location())
|
dayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location())
|
||||||
hasMaintenanceEveryDay := len(c.Every) == 0
|
hasMaintenanceEveryDay := len(c.Every) == 0
|
||||||
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String())
|
hasMaintenancePeriodScheduledToStartOnThatWeekday := slices.Contains(c.Every, dayWhereMaintenancePeriodWouldStart.Weekday().String())
|
||||||
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
|
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
|
||||||
// The day when the maintenance period would start is not scheduled
|
// The day when the maintenance period would start is not scheduled
|
||||||
// to have any maintenance, so we can just return false.
|
// to have any maintenance, so we can just return false.
|
||||||
@@ -129,15 +124,6 @@ func (c *Config) IsUnderMaintenance() bool {
|
|||||||
return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)
|
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) {
|
func hhmmToDuration(s string) (time.Duration, error) {
|
||||||
if len(s) != 5 {
|
if len(s) != 5 {
|
||||||
return 0, errInvalidMaintenanceStartFormat
|
return 0, errInvalidMaintenanceStartFormat
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ func New(config *Config) *SSHTunnel {
|
|||||||
tunnel := &SSHTunnel{
|
tunnel := &SSHTunnel{
|
||||||
config: config,
|
config: config,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse authentication methods once during initialization to avoid
|
// Parse authentication methods once during initialization to avoid
|
||||||
// expensive cryptographic operations on every connection attempt
|
// expensive cryptographic operations on every connection attempt
|
||||||
if config.PrivateKey != "" {
|
if config.PrivateKey != "" {
|
||||||
@@ -66,7 +65,6 @@ func New(config *Config) *SSHTunnel {
|
|||||||
} else if config.Password != "" {
|
} else if config.Password != "" {
|
||||||
tunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}
|
tunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tunnel
|
return tunnel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,27 +129,34 @@ func (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {
|
|||||||
client = t.client
|
client = t.client
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
}
|
}
|
||||||
// Create connection through SSH tunnel
|
// Attempt dial with exponential backoff retry
|
||||||
conn, err := client.Dial(network, addr)
|
const maxRetries = 3
|
||||||
if err != nil {
|
const baseDelay = 500 * time.Millisecond
|
||||||
// Close stale connection before retry to prevent leak
|
var lastErr error
|
||||||
|
for attempt := range maxRetries {
|
||||||
|
if attempt > 0 {
|
||||||
|
// Exponential backoff: 500ms, 1s, 2s
|
||||||
|
delay := baseDelay << (attempt - 1)
|
||||||
|
time.Sleep(delay)
|
||||||
|
// Close stale connection and reconnect
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if t.client != nil {
|
if t.client != nil {
|
||||||
t.client.Close()
|
_ = t.client.Close()
|
||||||
t.client = nil
|
t.client = nil
|
||||||
}
|
}
|
||||||
|
if err := t.connectUnsafe(); err != nil {
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
// Retry once - connection might be stale
|
lastErr = fmt.Errorf("reconnect attempt %d failed: %w", attempt, err)
|
||||||
if connErr := t.Connect(); connErr != nil {
|
continue
|
||||||
return nil, fmt.Errorf("SSH tunnel dial failed: %w (retry failed: %v)", err, connErr)
|
|
||||||
}
|
}
|
||||||
t.mu.RLock()
|
|
||||||
client = t.client
|
client = t.client
|
||||||
t.mu.RUnlock()
|
t.mu.Unlock()
|
||||||
conn, err = client.Dial(network, addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SSH tunnel dial failed after retry: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
conn, err := client.Dial(network, addr)
|
||||||
|
if err == nil {
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("SSH tunnel dial failed after %d attempts: %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|||||||
+42
-1
@@ -13,11 +13,17 @@ const (
|
|||||||
defaultTitle = "Health Dashboard | Gatus"
|
defaultTitle = "Health Dashboard | Gatus"
|
||||||
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
|
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
|
||||||
defaultHeader = "Gatus"
|
defaultHeader = "Gatus"
|
||||||
|
defaultDashboardHeading = "Health Dashboard"
|
||||||
|
defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
|
||||||
defaultLogo = ""
|
defaultLogo = ""
|
||||||
defaultLink = ""
|
defaultLink = ""
|
||||||
|
defaultFavicon = "/favicon.ico"
|
||||||
|
defaultFavicon16 = "/favicon-16x16.png"
|
||||||
|
defaultFavicon32 = "/favicon-32x32.png"
|
||||||
defaultCustomCSS = ""
|
defaultCustomCSS = ""
|
||||||
defaultSortBy = "name"
|
defaultSortBy = "name"
|
||||||
defaultFilterBy = "none"
|
defaultFilterBy = "none"
|
||||||
|
defaultLoginSubtitle = "System Monitoring Dashboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -32,15 +38,18 @@ var (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Title string `yaml:"title,omitempty"` // Title of the page
|
Title string `yaml:"title,omitempty"` // Title of the page
|
||||||
Description string `yaml:"description,omitempty"` // Meta description of the page
|
Description string `yaml:"description,omitempty"` // Meta description of the page
|
||||||
|
DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints
|
||||||
|
DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints
|
||||||
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
|
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
|
||||||
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||||
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||||
|
Favicon Favicon `yaml:"favicon,omitempty"` // Favourite icon to display in web browser tab or address bar
|
||||||
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||||
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
|
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
|
||||||
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
|
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
|
||||||
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
|
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
|
||||||
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
|
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
|
||||||
|
LoginSubtitle string `yaml:"login-subtitle,omitempty"` // LoginSubtitle is the subtitle displayed on the OIDC login page
|
||||||
//////////////////////////////////////////////
|
//////////////////////////////////////////////
|
||||||
// Non-configurable - used for UI rendering //
|
// Non-configurable - used for UI rendering //
|
||||||
//////////////////////////////////////////////
|
//////////////////////////////////////////////
|
||||||
@@ -68,11 +77,19 @@ func (btn *Button) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Favicon struct {
|
||||||
|
Default string `yaml:"default,omitempty"` // URL or path to default favourite icon.
|
||||||
|
Size16x16 string `yaml:"size16x16,omitempty"` // URL or path to favourite icon for 16x16 size.
|
||||||
|
Size32x32 string `yaml:"size32x32,omitempty"` // URL or path to favourite icon for 32x32 size.
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultConfig returns a Config struct with the default values
|
// GetDefaultConfig returns a Config struct with the default values
|
||||||
func GetDefaultConfig() *Config {
|
func GetDefaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Title: defaultTitle,
|
Title: defaultTitle,
|
||||||
Description: defaultDescription,
|
Description: defaultDescription,
|
||||||
|
DashboardHeading: defaultDashboardHeading,
|
||||||
|
DashboardSubheading: defaultDashboardSubheading,
|
||||||
Header: defaultHeader,
|
Header: defaultHeader,
|
||||||
Logo: defaultLogo,
|
Logo: defaultLogo,
|
||||||
Link: defaultLink,
|
Link: defaultLink,
|
||||||
@@ -80,7 +97,13 @@ func GetDefaultConfig() *Config {
|
|||||||
DarkMode: &defaultDarkMode,
|
DarkMode: &defaultDarkMode,
|
||||||
DefaultSortBy: defaultSortBy,
|
DefaultSortBy: defaultSortBy,
|
||||||
DefaultFilterBy: defaultFilterBy,
|
DefaultFilterBy: defaultFilterBy,
|
||||||
|
LoginSubtitle: defaultLoginSubtitle,
|
||||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||||
|
Favicon: Favicon{
|
||||||
|
Default: defaultFavicon,
|
||||||
|
Size16x16: defaultFavicon16,
|
||||||
|
Size32x32: defaultFavicon32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +115,12 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
|||||||
if len(cfg.Description) == 0 {
|
if len(cfg.Description) == 0 {
|
||||||
cfg.Description = defaultDescription
|
cfg.Description = defaultDescription
|
||||||
}
|
}
|
||||||
|
if len(cfg.DashboardHeading) == 0 {
|
||||||
|
cfg.DashboardHeading = defaultDashboardHeading
|
||||||
|
}
|
||||||
|
if len(cfg.DashboardSubheading) == 0 {
|
||||||
|
cfg.DashboardSubheading = defaultDashboardSubheading
|
||||||
|
}
|
||||||
if len(cfg.Header) == 0 {
|
if len(cfg.Header) == 0 {
|
||||||
cfg.Header = defaultHeader
|
cfg.Header = defaultHeader
|
||||||
}
|
}
|
||||||
@@ -117,6 +146,18 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
|||||||
} else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
|
} else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
|
||||||
return ErrInvalidDefaultFilterBy
|
return ErrInvalidDefaultFilterBy
|
||||||
}
|
}
|
||||||
|
if len(cfg.LoginSubtitle) == 0 {
|
||||||
|
cfg.LoginSubtitle = defaultLoginSubtitle
|
||||||
|
}
|
||||||
|
if len(cfg.Favicon.Default) == 0 {
|
||||||
|
cfg.Favicon.Default = defaultFavicon
|
||||||
|
}
|
||||||
|
if len(cfg.Favicon.Size16x16) == 0 {
|
||||||
|
cfg.Favicon.Size16x16 = defaultFavicon16
|
||||||
|
}
|
||||||
|
if len(cfg.Favicon.Size32x32) == 0 {
|
||||||
|
cfg.Favicon.Size32x32 = defaultFavicon32
|
||||||
|
}
|
||||||
for _, btn := range cfg.Buttons {
|
for _, btn := range cfg.Buttons {
|
||||||
if err := btn.Validate(); err != nil {
|
if err := btn.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||||
|
t.Run("empty-config", func(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Title: "",
|
Title: "",
|
||||||
Description: "",
|
Description: "",
|
||||||
|
DashboardHeading: "",
|
||||||
|
DashboardSubheading: "",
|
||||||
Header: "",
|
Header: "",
|
||||||
Logo: "",
|
Logo: "",
|
||||||
Link: "",
|
Link: "",
|
||||||
@@ -23,6 +26,12 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
|||||||
if cfg.Description != defaultDescription {
|
if cfg.Description != defaultDescription {
|
||||||
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
|
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
|
||||||
}
|
}
|
||||||
|
if cfg.DashboardHeading != defaultDashboardHeading {
|
||||||
|
t.Errorf("expected DashboardHeading to be %s, got %s", defaultDashboardHeading, cfg.DashboardHeading)
|
||||||
|
}
|
||||||
|
if cfg.DashboardSubheading != defaultDashboardSubheading {
|
||||||
|
t.Errorf("expected DashboardSubheading to be %s, got %s", defaultDashboardSubheading, cfg.DashboardSubheading)
|
||||||
|
}
|
||||||
if cfg.Header != defaultHeader {
|
if cfg.Header != defaultHeader {
|
||||||
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
|
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
|
||||||
}
|
}
|
||||||
@@ -32,6 +41,95 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
|||||||
if cfg.DefaultFilterBy != defaultFilterBy {
|
if cfg.DefaultFilterBy != defaultFilterBy {
|
||||||
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
|
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
|
||||||
}
|
}
|
||||||
|
if cfg.Favicon.Default != defaultFavicon {
|
||||||
|
t.Errorf("expected favicon to be %s, got %s", defaultFavicon, cfg.Favicon.Default)
|
||||||
|
}
|
||||||
|
if cfg.Favicon.Size16x16 != defaultFavicon16 {
|
||||||
|
t.Errorf("expected favicon to be %s, got %s", defaultFavicon16, cfg.Favicon.Size16x16)
|
||||||
|
}
|
||||||
|
if cfg.Favicon.Size32x32 != defaultFavicon32 {
|
||||||
|
t.Errorf("expected favicon to be %s, got %s", defaultFavicon32, cfg.Favicon.Size32x32)
|
||||||
|
}
|
||||||
|
if cfg.LoginSubtitle != defaultLoginSubtitle {
|
||||||
|
t.Errorf("expected LoginSubtitle to be %s, got %s", defaultLoginSubtitle, cfg.LoginSubtitle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("custom-values", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Title: "Custom Title",
|
||||||
|
Description: "Custom Description",
|
||||||
|
DashboardHeading: "Production Status",
|
||||||
|
DashboardSubheading: "Monitor all production endpoints",
|
||||||
|
Header: "My Company",
|
||||||
|
Logo: "https://example.com/logo.png",
|
||||||
|
Link: "https://example.com",
|
||||||
|
DefaultSortBy: "health",
|
||||||
|
DefaultFilterBy: "failing",
|
||||||
|
LoginSubtitle: "Welcome",
|
||||||
|
}
|
||||||
|
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if cfg.Title != "Custom Title" {
|
||||||
|
t.Errorf("expected title to be preserved, got %s", cfg.Title)
|
||||||
|
}
|
||||||
|
if cfg.Description != "Custom Description" {
|
||||||
|
t.Errorf("expected description to be preserved, got %s", cfg.Description)
|
||||||
|
}
|
||||||
|
if cfg.DashboardHeading != "Production Status" {
|
||||||
|
t.Errorf("expected DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
|
||||||
|
}
|
||||||
|
if cfg.DashboardSubheading != "Monitor all production endpoints" {
|
||||||
|
t.Errorf("expected DashboardSubheading to be preserved, got %s", cfg.DashboardSubheading)
|
||||||
|
}
|
||||||
|
if cfg.Header != "My Company" {
|
||||||
|
t.Errorf("expected header to be preserved, got %s", cfg.Header)
|
||||||
|
}
|
||||||
|
if cfg.Logo != "https://example.com/logo.png" {
|
||||||
|
t.Errorf("expected logo to be preserved, got %s", cfg.Logo)
|
||||||
|
}
|
||||||
|
if cfg.Link != "https://example.com" {
|
||||||
|
t.Errorf("expected link to be preserved, got %s", cfg.Link)
|
||||||
|
}
|
||||||
|
if cfg.DefaultSortBy != "health" {
|
||||||
|
t.Errorf("expected defaultSortBy to be preserved, got %s", cfg.DefaultSortBy)
|
||||||
|
}
|
||||||
|
if cfg.DefaultFilterBy != "failing" {
|
||||||
|
t.Errorf("expected defaultFilterBy to be preserved, got %s", cfg.DefaultFilterBy)
|
||||||
|
}
|
||||||
|
if cfg.LoginSubtitle != "Welcome" {
|
||||||
|
t.Errorf("expected LoginSubtitle to be preserved, got %s", cfg.LoginSubtitle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("partial-custom-values", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Title: "Custom Title",
|
||||||
|
DashboardHeading: "My Dashboard",
|
||||||
|
Header: "",
|
||||||
|
DashboardSubheading: "",
|
||||||
|
}
|
||||||
|
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if cfg.Title != "Custom Title" {
|
||||||
|
t.Errorf("expected custom title to be preserved, got %s", cfg.Title)
|
||||||
|
}
|
||||||
|
if cfg.DashboardHeading != "My Dashboard" {
|
||||||
|
t.Errorf("expected custom DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
|
||||||
|
}
|
||||||
|
if cfg.DashboardSubheading != defaultDashboardSubheading {
|
||||||
|
t.Errorf("expected DashboardSubheading to use default, got %s", cfg.DashboardSubheading)
|
||||||
|
}
|
||||||
|
if cfg.Header != defaultHeader {
|
||||||
|
t.Errorf("expected header to use default, got %s", cfg.Header)
|
||||||
|
}
|
||||||
|
if cfg.Description != defaultDescription {
|
||||||
|
t.Errorf("expected description to use default, got %s", cfg.Description)
|
||||||
|
}
|
||||||
|
if cfg.LoginSubtitle != defaultLoginSubtitle {
|
||||||
|
t.Errorf("expected LoginSubtitle to use default, got %s", cfg.LoginSubtitle)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestButton_Validate(t *testing.T) {
|
func TestButton_Validate(t *testing.T) {
|
||||||
@@ -78,6 +176,12 @@ func TestGetDefaultConfig(t *testing.T) {
|
|||||||
if defaultConfig.Title != defaultTitle {
|
if defaultConfig.Title != defaultTitle {
|
||||||
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
|
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
|
||||||
}
|
}
|
||||||
|
if defaultConfig.DashboardHeading != defaultDashboardHeading {
|
||||||
|
t.Error("expected GetDefaultConfig() to return defaultDashboardHeading, got", defaultConfig.DashboardHeading)
|
||||||
|
}
|
||||||
|
if defaultConfig.DashboardSubheading != defaultDashboardSubheading {
|
||||||
|
t.Error("expected GetDefaultConfig() to return defaultDashboardSubheading, got", defaultConfig.DashboardSubheading)
|
||||||
|
}
|
||||||
if defaultConfig.Logo != defaultLogo {
|
if defaultConfig.Logo != defaultLogo {
|
||||||
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
|
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
|
||||||
}
|
}
|
||||||
@@ -87,6 +191,9 @@ func TestGetDefaultConfig(t *testing.T) {
|
|||||||
if defaultConfig.DefaultFilterBy != defaultFilterBy {
|
if defaultConfig.DefaultFilterBy != defaultFilterBy {
|
||||||
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
|
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
|
||||||
}
|
}
|
||||||
|
if defaultConfig.LoginSubtitle != defaultLoginSubtitle {
|
||||||
|
t.Error("expected GetDefaultConfig() to return defaultLoginSubtitle, got", defaultConfig.LoginSubtitle)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
|
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
|
||||||
|
|||||||
@@ -4,13 +4,3 @@ package config
|
|||||||
func toPtr[T any](value T) *T {
|
func toPtr[T any](value T) *T {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
// contains checks if a key exists in the slice
|
|
||||||
func contains[T comparable](slice []T, key T) bool {
|
|
||||||
for _, item := range slice {
|
|
||||||
if item == key {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,91 +1,103 @@
|
|||||||
module github.com/TwiN/gatus/v5
|
module github.com/TwiN/gatus/v5
|
||||||
|
|
||||||
go 1.24.4
|
go 1.26.3
|
||||||
|
|
||||||
toolchain go1.24.7
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.gitea.io/sdk/gitea v0.21.0
|
code.gitea.io/sdk/gitea v0.25.1
|
||||||
github.com/TwiN/deepmerge v0.2.2
|
github.com/TwiN/deepmerge v0.2.2
|
||||||
github.com/TwiN/g8/v2 v2.0.0
|
github.com/TwiN/g8/v2 v2.0.0
|
||||||
github.com/TwiN/gocache/v2 v2.4.0
|
github.com/TwiN/gocache/v2 v2.4.0
|
||||||
github.com/TwiN/health v1.6.0
|
github.com/TwiN/health v1.6.0
|
||||||
github.com/TwiN/logr v0.3.1
|
github.com/TwiN/logr v0.3.1
|
||||||
github.com/TwiN/whois v1.1.11
|
github.com/TwiN/whois v1.3.0
|
||||||
github.com/aws/aws-sdk-go v1.55.8
|
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||||
github.com/gofiber/fiber/v2 v2.52.8
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.24
|
||||||
|
github.com/coreos/go-oidc/v3 v3.18.0
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.13
|
||||||
github.com/google/go-github/v48 v48.2.0
|
github.com/google/go-github/v48 v48.2.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/lib/pq v1.10.9
|
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2
|
||||||
github.com/miekg/dns v1.1.68
|
github.com/lib/pq v1.12.3
|
||||||
github.com/prometheus-community/pro-bing v0.7.0
|
github.com/miekg/dns v1.1.72
|
||||||
github.com/prometheus/client_golang v1.23.0
|
github.com/prometheus-community/pro-bing v0.8.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/registrobr/rdap v1.1.8
|
github.com/registrobr/rdap v1.1.8
|
||||||
github.com/valyala/fasthttp v1.64.0
|
github.com/valyala/fasthttp v1.71.0
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.2
|
github.com/wcharczuk/go-chart/v2 v2.1.2
|
||||||
golang.org/x/crypto v0.40.0
|
golang.org/x/crypto v0.55.0
|
||||||
golang.org/x/net v0.42.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/oauth2 v0.30.0
|
golang.org/x/sync v0.22.0
|
||||||
golang.org/x/sync v0.17.0
|
google.golang.org/api v0.280.0
|
||||||
google.golang.org/api v0.242.0
|
google.golang.org/grpc v1.81.1
|
||||||
gopkg.in/mail.v2 v2.3.1
|
gopkg.in/mail.v2 v2.3.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.38.2
|
modernc.org/sqlite v1.50.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/auth v0.16.2 // indirect
|
cloud.google.com/go/auth v0.20.0 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
github.com/42wim/httpsig v1.2.2 // indirect
|
github.com/42wim/httpsig v1.2.4 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
||||||
|
github.com/aws/smithy-go v1.25.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.2.0 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
|
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
github.com/klauspost/compress v1.18.6 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.65.0 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.20.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
golang.org/x/image v0.18.0 // indirect
|
golang.org/x/image v0.40.0 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.38.0 // indirect
|
||||||
golang.org/x/sys v0.34.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
golang.org/x/tools v0.48.0 // indirect
|
||||||
google.golang.org/grpc v1.73.0 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
modernc.org/libc v1.66.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
|
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||||
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
|
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||||
code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
|
code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
|
||||||
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
|
code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
|
||||||
github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA=
|
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||||
github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
|
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||||
github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
|
github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
|
||||||
github.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
|
github.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
|
||||||
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
|
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
|
||||||
@@ -18,21 +18,50 @@ github.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM=
|
|||||||
github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
|
github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
|
||||||
github.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
|
github.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
|
||||||
github.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw=
|
github.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw=
|
||||||
github.com/TwiN/whois v1.1.11 h1:lYiYgPRSQ3kH8sQfgHcBY/uNSGGvWPRikEjn+LJZ9+Q=
|
github.com/TwiN/whois v1.3.0 h1:V2+IUh5OGim8F3axTOMSlVmxNRaBrgpyiv5U0Dvbt5I=
|
||||||
github.com/TwiN/whois v1.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
|
github.com/TwiN/whois v1.3.0/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
|
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
|
||||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
|
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
|
||||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.24 h1:ZPCoU087iv2IWJKNQyN+MYOqZAM9lEVevS9MHWGG4wI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ses v1.34.24/go.mod h1:4T8OWyQ9nkdUrcqHHJkyibP5TfcWDTe4kWL40tiCOcs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||||
|
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||||
|
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
@@ -43,119 +72,115 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
|||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
|
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
|
||||||
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE=
|
github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE=
|
||||||
github.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y=
|
github.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y=
|
||||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo=
|
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
|
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2 h1:36qep4gxKs+JgeHGWeQ040RyZdt9kQlLglL1rFVn/oQ=
|
||||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
|
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
|
||||||
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
|
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
|
||||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||||
github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=
|
github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=
|
||||||
github.com/registrobr/rdap v1.1.8/go.mod h1:VY2DVrpsJpUfy9gj2QvurGymCgZV11/11cxQz5CxO+w=
|
github.com/registrobr/rdap v1.1.8/go.mod h1:VY2DVrpsJpUfy9gj2QvurGymCgZV11/11cxQz5CxO+w=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og=
|
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||||
github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA=
|
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
|
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
|
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
@@ -163,19 +188,18 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
|||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
|
||||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
|
||||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
|
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||||
|
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
@@ -185,33 +209,31 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -220,8 +242,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
|||||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
@@ -231,28 +253,33 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||||
|
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -260,33 +287,32 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
|||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func extractValue(currentKey string, value interface{}) interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
arrayIndex, err := strconv.Atoi(index)
|
arrayIndex, err := strconv.Atoi(index)
|
||||||
if err != nil {
|
if err != nil || arrayIndex < 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
currentKeyWithoutIndex := currentKey[:startOfBracket]
|
currentKeyWithoutIndex := currentKey[:startOfBracket]
|
||||||
|
|||||||
@@ -174,6 +174,38 @@ func TestEval(t *testing.T) {
|
|||||||
ExpectedOutputLength: 18,
|
ExpectedOutputLength: 18,
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "negative-index-on-keyed-array",
|
||||||
|
Path: "data[-1]",
|
||||||
|
Data: `{"data": [1, 2, 3]}`,
|
||||||
|
ExpectedOutput: "",
|
||||||
|
ExpectedOutputLength: 0,
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "negative-index-on-root-array",
|
||||||
|
Path: "[-1]",
|
||||||
|
Data: `[1, 2, 3]`,
|
||||||
|
ExpectedOutput: "",
|
||||||
|
ExpectedOutputLength: 0,
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "negative-index-followed-by-key",
|
||||||
|
Path: "data[-1].name",
|
||||||
|
Data: `{"data": [{"name": "value"}]}`,
|
||||||
|
ExpectedOutput: "",
|
||||||
|
ExpectedOutputLength: 0,
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "negative-index-nested-array",
|
||||||
|
Path: "data[0][-1]",
|
||||||
|
Data: `{"data": [[1, 2, 3]]}`,
|
||||||
|
ExpectedOutput: "",
|
||||||
|
ExpectedOutputLength: 0,
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -183,6 +183,33 @@ func initializeStorage(cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Load persisted triggered alerts for suite endpoints
|
||||||
|
for _, suite := range cfg.Suites {
|
||||||
|
for _, ep := range suite.Endpoints {
|
||||||
|
var checksums []string
|
||||||
|
for _, alert := range ep.Alerts {
|
||||||
|
if alert.IsEnabled() {
|
||||||
|
checksums = append(checksums, alert.Checksum())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums)
|
||||||
|
if numberOfTriggeredAlertsDeleted > 0 {
|
||||||
|
logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for suite endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key())
|
||||||
|
}
|
||||||
|
for _, alert := range ep.Alerts {
|
||||||
|
exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert)
|
||||||
|
if err != nil {
|
||||||
|
logr.Errorf("[main.initializeStorage] Failed to get triggered alert for suite endpoint with key=%s: %s", ep.Key(), err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
alert.Triggered, alert.ResolveKey = true, resolveKey
|
||||||
|
ep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold
|
||||||
|
numberOfPersistedTriggeredAlertsLoaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if numberOfPersistedTriggeredAlertsLoaded > 0 {
|
if numberOfPersistedTriggeredAlertsLoaded > 0 {
|
||||||
logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded)
|
logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ var (
|
|||||||
resultConnectedTotal *prometheus.CounterVec
|
resultConnectedTotal *prometheus.CounterVec
|
||||||
resultCodeTotal *prometheus.CounterVec
|
resultCodeTotal *prometheus.CounterVec
|
||||||
resultCertificateExpirationSeconds *prometheus.GaugeVec
|
resultCertificateExpirationSeconds *prometheus.GaugeVec
|
||||||
|
resultDomainExpirationSeconds *prometheus.GaugeVec
|
||||||
resultEndpointSuccess *prometheus.GaugeVec
|
resultEndpointSuccess *prometheus.GaugeVec
|
||||||
|
|
||||||
// Suite metrics
|
// Suite metrics
|
||||||
@@ -51,6 +52,9 @@ func UnregisterPrometheusMetrics() {
|
|||||||
if resultCertificateExpirationSeconds != nil {
|
if resultCertificateExpirationSeconds != nil {
|
||||||
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
|
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
|
||||||
}
|
}
|
||||||
|
if resultDomainExpirationSeconds != nil {
|
||||||
|
currentRegisterer.Unregister(resultDomainExpirationSeconds)
|
||||||
|
}
|
||||||
if resultEndpointSuccess != nil {
|
if resultEndpointSuccess != nil {
|
||||||
currentRegisterer.Unregister(resultEndpointSuccess)
|
currentRegisterer.Unregister(resultEndpointSuccess)
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,13 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
|
|||||||
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
||||||
reg.MustRegister(resultCertificateExpirationSeconds)
|
reg.MustRegister(resultCertificateExpirationSeconds)
|
||||||
|
|
||||||
|
resultDomainExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
|
Namespace: namespace,
|
||||||
|
Name: "results_domain_expiration_seconds",
|
||||||
|
Help: "Number of seconds until the domain expires",
|
||||||
|
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
||||||
|
reg.MustRegister(resultDomainExpirationSeconds)
|
||||||
|
|
||||||
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
Name: "results_endpoint_success",
|
Name: "results_endpoint_success",
|
||||||
@@ -178,6 +189,9 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
|
|||||||
if result.CertificateExpiration != 0 {
|
if result.CertificateExpiration != 0 {
|
||||||
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
|
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
|
||||||
}
|
}
|
||||||
|
if result.DomainExpiration != 0 {
|
||||||
|
resultDomainExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.DomainExpiration.Seconds())
|
||||||
|
}
|
||||||
if result.Success {
|
if result.Success {
|
||||||
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
|
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ func TestInitializePrometheusMetrics(t *testing.T) {
|
|||||||
if resultCertificateExpirationSeconds == nil {
|
if resultCertificateExpirationSeconds == nil {
|
||||||
t.Error("resultCertificateExpirationSeconds metric not initialized")
|
t.Error("resultCertificateExpirationSeconds metric not initialized")
|
||||||
}
|
}
|
||||||
|
if resultDomainExpirationSeconds == nil {
|
||||||
|
t.Error("resultDomainExpirationSeconds metric not initialized")
|
||||||
|
}
|
||||||
if resultEndpointSuccess == nil {
|
if resultEndpointSuccess == nil {
|
||||||
t.Error("resultEndpointSuccess metric not initialized")
|
t.Error("resultEndpointSuccess metric not initialized")
|
||||||
}
|
}
|
||||||
@@ -120,9 +123,11 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
|
|||||||
ConditionResults: []*endpoint.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[STATUS] == 200", Success: true},
|
{Condition: "[STATUS] == 200", Success: true},
|
||||||
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
|
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
|
||||||
|
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
|
||||||
},
|
},
|
||||||
Success: true,
|
Success: true,
|
||||||
CertificateExpiration: 49 * time.Hour,
|
CertificateExpiration: 49 * time.Hour,
|
||||||
|
DomainExpiration: 25 * time.Hour,
|
||||||
}, []string{})
|
}, []string{})
|
||||||
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||||
# HELP gatus_results_code_total Total number of results by code
|
# HELP gatus_results_code_total Total number of results by code
|
||||||
@@ -140,6 +145,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
|
|||||||
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
# TYPE gatus_results_certificate_expiration_seconds gauge
|
||||||
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 176400
|
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 176400
|
||||||
|
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
|
||||||
|
# TYPE gatus_results_domain_expiration_seconds gauge
|
||||||
|
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 90000
|
||||||
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
|
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
|
||||||
# TYPE gatus_results_endpoint_success gauge
|
# TYPE gatus_results_endpoint_success gauge
|
||||||
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
|
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
|
||||||
@@ -154,9 +162,11 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
|
|||||||
ConditionResults: []*endpoint.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[STATUS] == 200", Success: true},
|
{Condition: "[STATUS] == 200", Success: true},
|
||||||
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
|
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
|
||||||
|
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
|
||||||
},
|
},
|
||||||
Success: false,
|
Success: false,
|
||||||
CertificateExpiration: 47 * time.Hour,
|
CertificateExpiration: 47 * time.Hour,
|
||||||
|
DomainExpiration: 24 * time.Hour,
|
||||||
}, []string{})
|
}, []string{})
|
||||||
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||||
# HELP gatus_results_code_total Total number of results by code
|
# HELP gatus_results_code_total Total number of results by code
|
||||||
@@ -175,6 +185,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
|
|||||||
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
|
||||||
# TYPE gatus_results_certificate_expiration_seconds gauge
|
# TYPE gatus_results_certificate_expiration_seconds gauge
|
||||||
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
|
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
|
||||||
|
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
|
||||||
|
# TYPE gatus_results_domain_expiration_seconds gauge
|
||||||
|
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 86400
|
||||||
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
|
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
|
||||||
# TYPE gatus_results_endpoint_success gauge
|
# TYPE gatus_results_endpoint_success gauge
|
||||||
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
|
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ type Config struct {
|
|||||||
|
|
||||||
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
|
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
|
||||||
func (c *Config) ValidateAndSetDefaults() bool {
|
func (c *Config) ValidateAndSetDefaults() bool {
|
||||||
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())
|
return (c.Basic == nil || c.Basic.isValid()) && (c.OIDC == nil || c.OIDC.ValidateAndSetDefaults())
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterHandlers registers all handlers required based on the security configuration
|
// RegisterHandlers registers all handlers required based on the security configuration
|
||||||
|
|||||||
+73
-3
@@ -10,12 +10,82 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||||
c := &Config{
|
validBasicConfig := &BasicConfig{
|
||||||
|
Username: "test",
|
||||||
|
PasswordBcryptHashBase64Encoded: "somevalue",
|
||||||
|
}
|
||||||
|
validOIDCConfig := &OIDCConfig{
|
||||||
|
IssuerURL: "testurl",
|
||||||
|
RedirectURL: "testredirecturl/authorization-code/callback",
|
||||||
|
ClientID: "testid",
|
||||||
|
ClientSecret: "testsecret",
|
||||||
|
Scopes: []string{"testscope"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scenario struct {
|
||||||
|
Name string
|
||||||
|
Config *Config
|
||||||
|
ExpectValid bool
|
||||||
|
}
|
||||||
|
scenarios := []Scenario{
|
||||||
|
{
|
||||||
|
Name: "empty",
|
||||||
|
Config: &Config{
|
||||||
Basic: nil,
|
Basic: nil,
|
||||||
OIDC: nil,
|
OIDC: nil,
|
||||||
|
},
|
||||||
|
ExpectValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "empty-basic",
|
||||||
|
Config: &Config{
|
||||||
|
Basic: &BasicConfig{},
|
||||||
|
OIDC: nil,
|
||||||
|
},
|
||||||
|
ExpectValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "empty-oidc",
|
||||||
|
Config: &Config{
|
||||||
|
Basic: nil,
|
||||||
|
OIDC: &OIDCConfig{},
|
||||||
|
},
|
||||||
|
ExpectValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "valid-basic-only",
|
||||||
|
Config: &Config{
|
||||||
|
Basic: validBasicConfig,
|
||||||
|
OIDC: nil,
|
||||||
|
},
|
||||||
|
ExpectValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "valid-oidc-only",
|
||||||
|
Config: &Config{
|
||||||
|
Basic: nil,
|
||||||
|
OIDC: validOIDCConfig,
|
||||||
|
},
|
||||||
|
ExpectValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "valid-basic-and-oidc",
|
||||||
|
Config: &Config{
|
||||||
|
Basic: validBasicConfig,
|
||||||
|
OIDC: validOIDCConfig,
|
||||||
|
},
|
||||||
|
ExpectValid: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if c.ValidateAndSetDefaults() {
|
for _, scenario := range scenarios {
|
||||||
t.Error("expected empty config to be valid")
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
isValid := scenario.Config.ValidateAndSetDefaults()
|
||||||
|
if isValid && !scenario.ExpectValid {
|
||||||
|
t.Errorf("scenario %s: expected config to be invalid", scenario.Name)
|
||||||
|
} else if !isValid && scenario.ExpectValid {
|
||||||
|
t.Errorf("scenario %s: expected config to be valid", scenario.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package memory
|
package memory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -237,13 +238,7 @@ func (s *Store) InsertSuiteResult(su *suite.Suite, result *suite.Result) error {
|
|||||||
func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int {
|
func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int {
|
||||||
var keysToDelete []string
|
var keysToDelete []string
|
||||||
for _, existingKey := range s.endpointCache.GetKeysByPattern("*", 0) {
|
for _, existingKey := range s.endpointCache.GetKeysByPattern("*", 0) {
|
||||||
shouldDelete := true
|
shouldDelete := !slices.Contains(keys, existingKey)
|
||||||
for _, k := range keys {
|
|
||||||
if existingKey == k {
|
|
||||||
shouldDelete = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if shouldDelete {
|
if shouldDelete {
|
||||||
keysToDelete = append(keysToDelete, existingKey)
|
keysToDelete = append(keysToDelete, existingKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -797,7 +797,7 @@ func TestSuiteResultOrdering(t *testing.T) {
|
|||||||
baseTime := time.Now().Add(-5 * time.Hour)
|
baseTime := time.Now().Add(-5 * time.Hour)
|
||||||
timestamps := make([]time.Time, 5)
|
timestamps := make([]time.Time, 5)
|
||||||
|
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
timestamp := baseTime.Add(time.Duration(i) * time.Hour)
|
timestamp := baseTime.Add(time.Duration(i) * time.Hour)
|
||||||
timestamps[i] = timestamp
|
timestamps[i] = timestamp
|
||||||
result := &suite.Result{
|
result := &suite.Result{
|
||||||
@@ -878,7 +878,7 @@ func TestSuiteResultOrdering(t *testing.T) {
|
|||||||
smallSuite := &suite.Suite{Name: "small-suite", Group: "test"}
|
smallSuite := &suite.Suite{Name: "small-suite", Group: "test"}
|
||||||
|
|
||||||
// Insert 6 results, should keep only the newest 3
|
// Insert 6 results, should keep only the newest 3
|
||||||
for i := 0; i < 6; i++ {
|
for i := range 6 {
|
||||||
result := &suite.Result{
|
result := &suite.Result{
|
||||||
Name: smallSuite.Name,
|
Name: smallSuite.Name,
|
||||||
Group: smallSuite.Group,
|
Group: smallSuite.Group,
|
||||||
@@ -925,7 +925,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
|
|||||||
|
|
||||||
// Create endpoints for concurrent testing
|
// Create endpoints for concurrent testing
|
||||||
endpoints := make([]*endpoint.Endpoint, numGoroutines)
|
endpoints := make([]*endpoint.Endpoint, numGoroutines)
|
||||||
for i := 0; i < numGoroutines; i++ {
|
for i := range numGoroutines {
|
||||||
endpoints[i] = &endpoint.Endpoint{
|
endpoints[i] = &endpoint.Endpoint{
|
||||||
Name: "endpoint-" + string(rune('A'+i)),
|
Name: "endpoint-" + string(rune('A'+i)),
|
||||||
Group: "concurrent",
|
Group: "concurrent",
|
||||||
@@ -934,12 +934,12 @@ func TestStore_ConcurrentAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Concurrently insert results for different endpoints
|
// Concurrently insert results for different endpoints
|
||||||
for i := 0; i < numGoroutines; i++ {
|
for i := range numGoroutines {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(endpointIndex int) {
|
go func(endpointIndex int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
ep := endpoints[endpointIndex]
|
ep := endpoints[endpointIndex]
|
||||||
for j := 0; j < resultsPerGoroutine; j++ {
|
for j := range resultsPerGoroutine {
|
||||||
result := &endpoint.Result{
|
result := &endpoint.Result{
|
||||||
Success: j%2 == 0,
|
Success: j%2 == 0,
|
||||||
Timestamp: time.Now().Add(time.Duration(j) * time.Minute),
|
Timestamp: time.Now().Add(time.Duration(j) * time.Minute),
|
||||||
@@ -978,7 +978,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
|
|||||||
|
|
||||||
// Create suites for concurrent testing
|
// Create suites for concurrent testing
|
||||||
suites := make([]*suite.Suite, numGoroutines)
|
suites := make([]*suite.Suite, numGoroutines)
|
||||||
for i := 0; i < numGoroutines; i++ {
|
for i := range numGoroutines {
|
||||||
suites[i] = &suite.Suite{
|
suites[i] = &suite.Suite{
|
||||||
Name: "suite-" + string(rune('A'+i)),
|
Name: "suite-" + string(rune('A'+i)),
|
||||||
Group: "concurrent",
|
Group: "concurrent",
|
||||||
@@ -986,12 +986,12 @@ func TestStore_ConcurrentAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Concurrently insert results for different suites
|
// Concurrently insert results for different suites
|
||||||
for i := 0; i < numGoroutines; i++ {
|
for i := range numGoroutines {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(suiteIndex int) {
|
go func(suiteIndex int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
su := suites[suiteIndex]
|
su := suites[suiteIndex]
|
||||||
for j := 0; j < resultsPerGoroutine; j++ {
|
for j := range resultsPerGoroutine {
|
||||||
result := &suite.Result{
|
result := &suite.Result{
|
||||||
Name: su.Name,
|
Name: su.Name,
|
||||||
Group: su.Group,
|
Group: su.Group,
|
||||||
@@ -1036,7 +1036,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
result := &endpoint.Result{
|
result := &endpoint.Result{
|
||||||
Success: true,
|
Success: true,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
@@ -1050,7 +1050,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < 5; i++ {
|
for i := range 5 {
|
||||||
result := &suite.Result{
|
result := &suite.Result{
|
||||||
Name: testSuite.Name,
|
Name: testSuite.Name,
|
||||||
Group: testSuite.Group,
|
Group: testSuite.Group,
|
||||||
@@ -1066,7 +1066,7 @@ func TestStore_ConcurrentAccess(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
|
store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
|
||||||
store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
|
store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
|
||||||
time.Sleep(1 * time.Millisecond)
|
time.Sleep(1 * time.Millisecond)
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
func BenchmarkShallowCopyEndpointStatus(b *testing.B) {
|
func BenchmarkShallowCopyEndpointStatus(b *testing.B) {
|
||||||
ep := &testEndpoint
|
ep := &testEndpoint
|
||||||
status := endpoint.NewStatus(ep.Group, ep.Name)
|
status := endpoint.NewStatus(ep.Group, ep.Name)
|
||||||
for i := 0; i < storage.DefaultMaximumNumberOfResults; i++ {
|
for range storage.DefaultMaximumNumberOfResults {
|
||||||
AddResult(status, &testSuccessfulResult, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
AddResult(status, &testSuccessfulResult, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||||
}
|
}
|
||||||
for n := 0; n < b.N; n++ {
|
for b.Loop() {
|
||||||
ShallowCopyEndpointStatus(status, paging.NewEndpointStatusParams().WithResults(1, 20))
|
ShallowCopyEndpointStatus(status, paging.NewEndpointStatusParams().WithResults(1, 20))
|
||||||
}
|
}
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
func TestAddResult(t *testing.T) {
|
func TestAddResult(t *testing.T) {
|
||||||
ep := &endpoint.Endpoint{Name: "name", Group: "group"}
|
ep := &endpoint.Endpoint{Name: "name", Group: "group"}
|
||||||
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
|
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
|
||||||
for i := 0; i < (storage.DefaultMaximumNumberOfResults+storage.DefaultMaximumNumberOfEvents)*2; i++ {
|
for i := range (storage.DefaultMaximumNumberOfResults + storage.DefaultMaximumNumberOfEvents) * 2 {
|
||||||
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||||
}
|
}
|
||||||
if len(endpointStatus.Results) != storage.DefaultMaximumNumberOfResults {
|
if len(endpointStatus.Results) != storage.DefaultMaximumNumberOfResults {
|
||||||
@@ -30,7 +30,7 @@ func TestShallowCopyEndpointStatus(t *testing.T) {
|
|||||||
ep := &endpoint.Endpoint{Name: "name", Group: "group"}
|
ep := &endpoint.Endpoint{Name: "name", Group: "group"}
|
||||||
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
|
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
|
||||||
ts := time.Now().Add(-25 * time.Hour)
|
ts := time.Now().Add(-25 * time.Hour)
|
||||||
for i := 0; i < 25; i++ {
|
for i := range 25 {
|
||||||
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: ts}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: ts}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
|
||||||
ts = ts.Add(time.Hour)
|
ts = ts.Add(time.Hour)
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ func TestShallowCopySuiteStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ts := time.Now().Add(-25 * time.Hour)
|
ts := time.Now().Add(-25 * time.Hour)
|
||||||
for i := 0; i < 25; i++ {
|
for i := range 25 {
|
||||||
result := &suite.Result{
|
result := &suite.Result{
|
||||||
Name: testSuite.Name,
|
Name: testSuite.Name,
|
||||||
Group: testSuite.Group,
|
Group: testSuite.Group,
|
||||||
@@ -169,4 +169,3 @@ func TestShallowCopySuiteStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,5 +120,9 @@ func (s *Store) createPostgresSchema() error {
|
|||||||
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD COLUMN IF NOT EXISTS suite_result_id BIGINT REFERENCES suite_results(suite_result_id) ON DELETE CASCADE`)
|
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD COLUMN IF NOT EXISTS suite_result_id BIGINT REFERENCES suite_results(suite_result_id) ON DELETE CASCADE`)
|
||||||
// Create index for suite_result_id
|
// Create index for suite_result_id
|
||||||
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS endpoint_results_suite_result_id_idx ON endpoint_results(suite_result_id)`)
|
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS endpoint_results_suite_result_id_idx ON endpoint_results(suite_result_id)`)
|
||||||
|
// Create index for endpoint_result_conditions
|
||||||
|
_, _ = s.db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_endpoint_result_conditions_endpoint_result_id ON endpoint_result_conditions (endpoint_result_id)`)
|
||||||
|
// Create index for endpoint_results
|
||||||
|
_, _ = s.db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_endpoint_results_endpoint_id ON endpoint_results (endpoint_id)`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-14
@@ -1176,23 +1176,26 @@ func (s *Store) GetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
|
// Collect all suite statuses first before making nested queries.
|
||||||
|
// The lib/pq driver requires the previous query to be fully closed before starting
|
||||||
|
// new queries in the same transaction, otherwise it returns "pq: unexpected Parse
|
||||||
|
// response 'C'" errors. See: https://github.com/TwiN/gatus/issues/1435
|
||||||
var suiteStatuses []*suite.Status
|
var suiteStatuses []*suite.Status
|
||||||
|
suiteIDs := make(map[*suite.Status]int64) // Track suite_id for each status
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
status := &suite.Status{Results: []*suite.Result{}}
|
||||||
var suiteID int64
|
var suiteID int64
|
||||||
var key, name, group string
|
if err = rows.Scan(&suiteID, &status.Key, &status.Name, &status.Group); err != nil {
|
||||||
if err = rows.Scan(&suiteID, &key, &name, &group); err != nil {
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
suiteStatuses = append(suiteStatuses, status)
|
||||||
status := &suite.Status{
|
suiteIDs[status] = suiteID
|
||||||
Name: name,
|
|
||||||
Group: group,
|
|
||||||
Key: key,
|
|
||||||
Results: []*suite.Result{},
|
|
||||||
}
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, status := range suiteStatuses {
|
||||||
// Get suite results with pagination
|
// Get suite results with pagination
|
||||||
pageSize := 20
|
pageSize := 20
|
||||||
page := 1
|
page := 1
|
||||||
@@ -1205,17 +1208,16 @@ func (s *Store) GetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suiteID := suiteIDs[status]
|
||||||
status.Results, err = s.getSuiteResults(tx, suiteID, page, pageSize)
|
status.Results, err = s.getSuiteResults(tx, suiteID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logr.Errorf("[sql.GetAllSuiteStatuses] Failed to retrieve results for suite_id=%d: %s", suiteID, err.Error())
|
logr.Errorf("[sql.GetAllSuiteStatuses] Failed to retrieve results for suite_id=%d: %s", suiteID, err.Error())
|
||||||
}
|
}
|
||||||
// Populate Name and Group fields on each result
|
// Populate Name and Group fields on each result
|
||||||
for _, result := range status.Results {
|
for _, result := range status.Results {
|
||||||
result.Name = name
|
result.Name = status.Name
|
||||||
result.Group = group
|
result.Group = status.Group
|
||||||
}
|
}
|
||||||
|
|
||||||
suiteStatuses = append(suiteStatuses, status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
@@ -1466,7 +1468,6 @@ func (s *Store) getSuiteResults(tx *sql.Tx, suiteID int64, page, pageSize int) (
|
|||||||
logr.Errorf("[sql.getSuiteResults] Query failed: %v", err)
|
logr.Errorf("[sql.getSuiteResults] Query failed: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
type suiteResultData struct {
|
type suiteResultData struct {
|
||||||
result *suite.Result
|
result *suite.Result
|
||||||
id int64
|
id int64
|
||||||
@@ -1494,6 +1495,11 @@ func (s *Store) getSuiteResults(tx *sql.Tx, suiteID int64, page, pageSize int) (
|
|||||||
id: suiteResultID,
|
id: suiteResultID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Close the rows before making nested queries to avoid PostgreSQL driver issues.
|
||||||
|
// The lib/pq driver requires the previous query to be fully closed before starting
|
||||||
|
// new queries in the same transaction, otherwise it returns "pq: unexpected Parse
|
||||||
|
// response 'C'" errors. See: https://github.com/TwiN/gatus/issues/1435
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
// Reverse the results to get chronological order (oldest to newest)
|
// Reverse the results to get chronological order (oldest to newest)
|
||||||
for i := len(resultsData)/2 - 1; i >= 0; i-- {
|
for i := len(resultsData)/2 - 1; i >= 0; i-- {
|
||||||
|
|||||||
@@ -900,7 +900,7 @@ func TestEventOrderingFix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Create many events over time
|
// Create many events over time
|
||||||
baseTime := time.Now().Add(-100 * time.Hour) // Start 100 hours ago
|
baseTime := time.Now().Add(-100 * time.Hour) // Start 100 hours ago
|
||||||
for i := 0; i < 50; i++ {
|
for i := range 50 {
|
||||||
result := &endpoint.Result{
|
result := &endpoint.Result{
|
||||||
Success: i%2 == 0, // Alternate between true/false to create events
|
Success: i%2 == 0, // Alternate between true/false to create events
|
||||||
Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
|
Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ func BenchmarkStore_GetAllEndpointStatuses(b *testing.B) {
|
|||||||
numberOfEndpoints := []int{10, 25, 50, 100}
|
numberOfEndpoints := []int{10, 25, 50, 100}
|
||||||
for _, numberOfEndpointsToCreate := range numberOfEndpoints {
|
for _, numberOfEndpointsToCreate := range numberOfEndpoints {
|
||||||
// Create endpoints and insert results
|
// Create endpoints and insert results
|
||||||
for i := 0; i < numberOfEndpointsToCreate; i++ {
|
for i := range numberOfEndpointsToCreate {
|
||||||
ep := testEndpoint
|
ep := testEndpoint
|
||||||
ep.Name = "endpoint" + strconv.Itoa(i)
|
ep.Name = "endpoint" + strconv.Itoa(i)
|
||||||
// InsertEndpointResult 20 results for each endpoint
|
// InsertEndpointResult 20 results for each endpoint
|
||||||
for j := 0; j < 20; j++ {
|
for range 20 {
|
||||||
scenario.Store.InsertEndpointResult(&ep, &testSuccessfulResult)
|
scenario.Store.InsertEndpointResult(&ep, &testSuccessfulResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ func BenchmarkStore_GetEndpointStatusByKey(b *testing.B) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
for i := 0; i < 50; i++ {
|
for range 50 {
|
||||||
scenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
|
scenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
|
||||||
scenario.Store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
|
scenario.Store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-14
@@ -7,12 +7,12 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting"
|
"github.com/TwiN/gatus/v5/alerting"
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
@@ -325,19 +325,6 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: "jetbrainsspace",
|
|
||||||
AlertType: alert.TypeJetBrainsSpace,
|
|
||||||
AlertingConfig: &alerting.Config{
|
|
||||||
JetBrainsSpace: &jetbrainsspace.AlertProvider{
|
|
||||||
DefaultConfig: jetbrainsspace.Config{
|
|
||||||
Project: "foo",
|
|
||||||
ChannelID: "bar",
|
|
||||||
Token: "baz",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: "line",
|
Name: "line",
|
||||||
AlertType: alert.TypeLine,
|
AlertType: alert.TypeLine,
|
||||||
@@ -520,6 +507,18 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "clickup",
|
||||||
|
AlertType: alert.TypeClickUp,
|
||||||
|
AlertingConfig: &alerting.Config{
|
||||||
|
ClickUp: &clickup.AlertProvider{
|
||||||
|
DefaultConfig: clickup.Config{
|
||||||
|
ListID: "test-list-id",
|
||||||
|
Token: "test-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ func executeEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
|
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
|
||||||
// TODO: Consider moving this after the monitoring lock is unlocked? I mean, how much noise can a single alerting provider cause...
|
|
||||||
HandleAlerting(ep, result, cfg.Alerting)
|
HandleAlerting(ep, result, cfg.Alerting)
|
||||||
} else {
|
} else {
|
||||||
logr.Debug("[watchdog.executeEndpoint] Not handling alerting because currently in the maintenance window")
|
logr.Debug("[watchdog.executeEndpoint] Not handling alerting because currently in the maintenance window")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user