mirror of
https://github.com/TwiN/gatus.git
synced 2026-08-22 15:16:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
40345a03d3 | ||
|
|
97a2be3504 | ||
|
|
15a4133502 | ||
|
|
64a5043655 | ||
|
|
5a06a74cc3 | ||
|
|
d6fa2c955b | ||
|
|
e6576e9080 | ||
|
|
cd10b31ab5 | ||
|
|
d1ef0b72a4 | ||
|
|
327a39964d |
@@ -16,4 +16,10 @@ endpoints:
|
||||
url: https://api.github.com/healthz
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: check-domain-expiration
|
||||
url: "https://example.org/"
|
||||
interval: 1h
|
||||
conditions:
|
||||
- "[DOMAIN_EXPIRATION] > 720h"
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
container_name: gatus
|
||||
@@ -13,7 +12,7 @@ services:
|
||||
|
||||
prometheus:
|
||||
container_name: prometheus
|
||||
image: prom/prometheus:v2.14.0
|
||||
image: prom/prometheus:v3.5.0
|
||||
restart: always
|
||||
command: --config.file=/etc/prometheus/prometheus.yml
|
||||
ports:
|
||||
@@ -25,7 +24,7 @@ services:
|
||||
|
||||
grafana:
|
||||
container_name: grafana
|
||||
image: grafana/grafana:6.4.4
|
||||
image: grafana/grafana:12.1.0
|
||||
restart: always
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_PASSWORD: secret
|
||||
|
||||
+266
-129
@@ -19,7 +19,7 @@
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": 566,
|
||||
"id": 41,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
@@ -39,7 +39,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green"
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "yellow",
|
||||
@@ -79,7 +80,7 @@
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -122,7 +123,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red"
|
||||
"color": "red",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "yellow",
|
||||
@@ -162,7 +164,7 @@
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -193,7 +195,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green"
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -225,7 +228,7 @@
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -292,7 +295,7 @@
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -321,7 +324,7 @@
|
||||
"type": "prometheus",
|
||||
"uid": "$datasource"
|
||||
},
|
||||
"description": "SSL certificate expiration times for all services",
|
||||
"description": "Domain expiration times for all domains",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
@@ -339,7 +342,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red"
|
||||
"color": "red",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "#EAB839",
|
||||
@@ -395,7 +399,137 @@
|
||||
"showHeader": true,
|
||||
"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": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -444,6 +578,113 @@
|
||||
],
|
||||
"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": {
|
||||
"type": "prometheus",
|
||||
@@ -483,7 +724,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red"
|
||||
"color": "red",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
@@ -525,7 +767,7 @@
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
"y": 16
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
@@ -546,7 +788,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -689,7 +931,8 @@
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green"
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
@@ -703,9 +946,9 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 16
|
||||
"y": 24
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
@@ -721,7 +964,7 @@
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "12.0.2",
|
||||
"pluginVersion": "12.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
@@ -735,112 +978,6 @@
|
||||
],
|
||||
"title": "Response Times",
|
||||
"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,
|
||||
@@ -855,8 +992,8 @@
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"text": "myprom",
|
||||
"value": "PA04845DA3A4B088E"
|
||||
"text": "prometheus",
|
||||
"value": "cedv077q7bbwgd"
|
||||
},
|
||||
"description": "Select your Prometheus datasource",
|
||||
"includeAll": false,
|
||||
@@ -877,6 +1014,6 @@
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Gatus - Service Monitoring Dashboard",
|
||||
"uid": "gatus-monitoring2",
|
||||
"version": 10
|
||||
}
|
||||
"uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
|
||||
"version": 1
|
||||
}
|
||||
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 |
@@ -20,13 +20,14 @@ _Looking for a managed solution? Check out [Gatus.io](https://gatus.io)._
|
||||
<details>
|
||||
<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
|
||||
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)
|
||||
</details>
|
||||
|
||||
@@ -49,8 +50,10 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Conditions](#conditions)
|
||||
- [Placeholders](#placeholders)
|
||||
- [Functions](#functions)
|
||||
- [Announcements](#announcements)
|
||||
- [Storage](#storage)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Tunneling](#tunneling)
|
||||
- [Alerting](#alerting)
|
||||
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
|
||||
- [Configuring Datadog alerts](#configuring-datadog-alerts)
|
||||
@@ -65,11 +68,11 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Configuring IFTTT alerts](#configuring-ifttt-alerts)
|
||||
- [Configuring Ilert alerts](#configuring-ilert-alerts)
|
||||
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
|
||||
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
|
||||
- [Configuring Line alerts](#configuring-line-alerts)
|
||||
- [Configuring Matrix alerts](#configuring-matrix-alerts)
|
||||
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
||||
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
|
||||
- [Configuring n8n alerts](#configuring-n8n-alerts)
|
||||
- [Configuring New Relic alerts](#configuring-new-relic-alerts)
|
||||
- [Configuring Ntfy alerts](#configuring-ntfy-alerts)
|
||||
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
|
||||
@@ -93,7 +96,6 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Configuring Zulip alerts](#configuring-zulip-alerts)
|
||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||
- [Setting a default alert](#setting-a-default-alert)
|
||||
- [Announcements](#announcements)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Security](#security)
|
||||
- [Basic Authentication](#basic-authentication)
|
||||
@@ -107,6 +109,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Docker](#docker)
|
||||
- [Helm Chart](#helm-chart)
|
||||
- [Terraform](#terraform)
|
||||
- [Kubernetes](#kubernetes)
|
||||
- [Running the tests](#running-the-tests)
|
||||
- [Using in Production](#using-in-production)
|
||||
- [FAQ](#faq)
|
||||
@@ -117,6 +120,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)
|
||||
- [Monitoring a SCTP endpoint](#monitoring-a-sctp-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 DNS queries](#monitoring-an-endpoint-using-dns-queries)
|
||||
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
|
||||
@@ -184,18 +188,15 @@ The main features of Gatus are:
|
||||
|
||||
## Usage
|
||||
|
||||
<details>
|
||||
<summary><b>Quick start</b></summary>
|
||||
|
||||
```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
|
||||
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.
|
||||
</details>
|
||||
|
||||
Here's a simple example:
|
||||
```yaml
|
||||
@@ -262,6 +263,8 @@ If you want to test it locally, see [Docker](#docker).
|
||||
| `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. | `Gatus` |
|
||||
| `ui.logo` | URL to the logo to display. | `""` |
|
||||
| `ui.link` | Link to open when the logo is clicked. | `""` |
|
||||
@@ -310,6 +313,7 @@ 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-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-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.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. | `{}` |
|
||||
@@ -367,17 +371,17 @@ or send an HTTP request:
|
||||
POST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}
|
||||
```
|
||||
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`.
|
||||
- `{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.
|
||||
- `{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.
|
||||
|
||||
|
||||
### Suites (ALPHA)
|
||||
Suites are collections of endpoints that are executed sequentially with a shared context.
|
||||
Suites are collections of endpoints that are executed sequentially with a shared context.
|
||||
This allows you to create complex monitoring scenarios where the result from one endpoint can be used in subsequent endpoints, enabling workflow-style monitoring.
|
||||
|
||||
Here are a few cases in which suites could be useful:
|
||||
@@ -419,7 +423,7 @@ suites:
|
||||
context:
|
||||
price: "19.99" # Initial static value in context
|
||||
endpoints:
|
||||
# Step 1: Create an item and store the item ID
|
||||
# Step 1: Create an item and store the item ID
|
||||
- name: create-item
|
||||
url: https://api.example.com/items
|
||||
method: POST
|
||||
@@ -433,7 +437,7 @@ suites:
|
||||
alerts:
|
||||
- type: slack
|
||||
description: "Failed to create item"
|
||||
|
||||
|
||||
# Step 2: Update the item using the stored item ID
|
||||
- name: update-item
|
||||
url: https://api.example.com/items/[CONTEXT].itemId
|
||||
@@ -444,7 +448,7 @@ suites:
|
||||
alerts:
|
||||
- type: slack
|
||||
description: "Failed to update item"
|
||||
|
||||
|
||||
# Step 3: Fetch the item and validate the price
|
||||
- name: get-item
|
||||
url: https://api.example.com/items/[CONTEXT].itemId
|
||||
@@ -455,7 +459,7 @@ suites:
|
||||
alerts:
|
||||
- type: slack
|
||||
description: "Item price did not update correctly"
|
||||
|
||||
|
||||
# Step 4: Delete the item (always-run: true to ensure cleanup even if step 2 or 3 fails)
|
||||
- name: delete-item
|
||||
url: https://api.example.com/items/[CONTEXT].itemId
|
||||
@@ -523,18 +527,21 @@ Here are some examples of conditions you can use:
|
||||
|
||||
|
||||
### 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.
|
||||
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.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------|:----------------------------------------------------------------------------------------------|:---------|
|
||||
| `announcements` | List of announcements to display | `[]` |
|
||||
| `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[].message` | The message to display to users | Required |
|
||||
This is essentially what some status page calls "incident communications".
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------|:-------------------------------------------------------------------------------------------------------------------------|:---------|
|
||||
| `announcements` | List of announcements to display | `[]` |
|
||||
| `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[].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:
|
||||
- **outage**: Indicates service disruptions or critical issues (red theme)
|
||||
- **warning**: Indicates potential issues or important notices (yellow theme)
|
||||
- **warning**: Indicates potential issues or important notices (yellow theme)
|
||||
- **information**: General information or updates (blue theme)
|
||||
- **operational**: Indicates resolved issues or normal operations (green theme)
|
||||
- **none**: Neutral announcements with no specific severity (gray theme, default if none are specified)
|
||||
@@ -542,17 +549,24 @@ Types:
|
||||
Example Configuration:
|
||||
```yaml
|
||||
announcements:
|
||||
- timestamp: 2025-08-15T14:00:00Z
|
||||
- timestamp: 2025-11-07T14:00:00Z
|
||||
type: outage
|
||||
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
|
||||
- timestamp: 2025-08-15T16:15:00Z
|
||||
type: operational
|
||||
- timestamp: 2025-11-07T16:15:00Z
|
||||
type: operational
|
||||
message: "Database maintenance completed successfully. All systems operational."
|
||||
- timestamp: 2025-08-15T12:00:00Z
|
||||
- timestamp: 2025-11-07T12:00:00Z
|
||||
type: information
|
||||
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
|
||||
| Parameter | Description | Default |
|
||||
@@ -597,24 +611,25 @@ See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres
|
||||
In order to support a wide range of environments, each monitored endpoint has a unique configuration for
|
||||
the client used to send the request.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------|:----------------------------------------------------------------------------|:----------------|
|
||||
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
|
||||
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
|
||||
| `client.timeout` | Duration before timing out. | `10s` |
|
||||
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
|
||||
| `client.oauth2` | OAuth2 client configuration. | `{}` |
|
||||
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
|
||||
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
|
||||
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
|
||||
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
|
||||
| `client.proxy-url` | The URL of the proxy to use for the client | `""` |
|
||||
| `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` |
|
||||
| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` |
|
||||
| `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` |
|
||||
| `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` |
|
||||
| `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"` |
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------|:------------------------------------------------------------------------------|:----------------|
|
||||
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
|
||||
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
|
||||
| `client.timeout` | Duration before timing out. | `10s` |
|
||||
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
|
||||
| `client.oauth2` | OAuth2 client configuration. | `{}` |
|
||||
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
|
||||
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
|
||||
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
|
||||
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
|
||||
| `client.proxy-url` | The URL of the proxy to use for the client | `""` |
|
||||
| `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` |
|
||||
| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` |
|
||||
| `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` |
|
||||
| `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` |
|
||||
| `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.tunnel` | Name of the SSH tunnel to use for this endpoint. See [Tunneling](#tunneling). | `""` |
|
||||
|
||||
|
||||
> 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
|
||||
@@ -705,23 +720,65 @@ endpoints:
|
||||
|
||||
> 📝 Note that if running in a container, you must volume mount the certificate and key into the container.
|
||||
|
||||
### Tunneling
|
||||
Gatus supports SSH tunneling to monitor internal services through jump hosts or bastion servers.
|
||||
This is particularly useful for monitoring services that are not directly accessible from where Gatus is deployed.
|
||||
|
||||
SSH tunnels are defined globally in the `tunneling` section and then referenced by name in endpoint client configurations.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------------|:------------------------------------------------------------|:--------------|
|
||||
| `tunneling` | SSH tunnel configurations | `{}` |
|
||||
| `tunneling.<tunnel-name>` | Configuration for a named SSH tunnel | `{}` |
|
||||
| `tunneling.<tunnel-name>.type` | Type of tunnel (currently only `SSH` is supported) | Required `""` |
|
||||
| `tunneling.<tunnel-name>.host` | SSH server hostname or IP address | Required `""` |
|
||||
| `tunneling.<tunnel-name>.port` | SSH server port | `22` |
|
||||
| `tunneling.<tunnel-name>.username` | SSH username | Required `""` |
|
||||
| `tunneling.<tunnel-name>.password` | SSH password (use either this or private-key) | `""` |
|
||||
| `tunneling.<tunnel-name>.private-key` | SSH private key in PEM format (use either this or password) | `""` |
|
||||
| `client.tunnel` | Name of the tunnel to use for this endpoint | `""` |
|
||||
|
||||
```yaml
|
||||
tunneling:
|
||||
production:
|
||||
type: SSH
|
||||
host: "jumphost.example.com"
|
||||
username: "monitoring"
|
||||
private-key: |
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEA...
|
||||
-----END RSA PRIVATE KEY-----
|
||||
|
||||
endpoints:
|
||||
- name: "internal-api"
|
||||
url: "http://internal-api.example.com:8080/health"
|
||||
client:
|
||||
tunnel: "production"
|
||||
conditions:
|
||||
- "[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
|
||||
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
|
||||
individual endpoints with configurable descriptions and thresholds.
|
||||
|
||||
Alerts are configured at the endpoint level like so:
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerts` | List of all alerts for a given endpoint. | `[]` |
|
||||
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
|
||||
| `alerts[].enabled` | Whether to enable the alert. | `true` |
|
||||
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
|
||||
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
|
||||
| `alerts[].minimum-reminder-interval` | Minimum time interval between alert reminders. E.g. `"30m"`, `"1h45m30s"` or `"24h"`. If empty or `0`, reminders are disabled. | `0` |
|
||||
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
|
||||
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
|
||||
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerts` | List of all alerts for a given endpoint. | `[]` |
|
||||
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
|
||||
| `alerts[].enabled` | Whether to enable the alert. | `true` |
|
||||
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
|
||||
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
|
||||
| `alerts[].minimum-reminder-interval` | Minimum time interval between alert reminders. E.g. `"30m"`, `"1h45m30s"` or `"24h"`. If empty or `0`, reminders are disabled. Cannot be lower than `5m`. | `0` |
|
||||
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
|
||||
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
|
||||
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
|
||||
|
||||
Here's an example of what an alert configuration might look like at the endpoint level:
|
||||
```yaml
|
||||
@@ -768,11 +825,11 @@ endpoints:
|
||||
| `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.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.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.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.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). | `{}` |
|
||||
@@ -878,6 +935,7 @@ endpoints:
|
||||
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
|
||||
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
|
||||
| `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.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 | `""` |
|
||||
@@ -1257,7 +1315,7 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
#### Configuring ilert alerts
|
||||
#### Configuring Ilert alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
|
||||
@@ -1339,42 +1397,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`
|
||||
|
||||
|
||||
#### 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
|
||||
|
||||
| Parameter | Description | Default |
|
||||
@@ -1535,8 +1557,8 @@ alerting:
|
||||
region: "US" # or "EU" for European region
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
- name: example
|
||||
url: "https://example.org"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
@@ -1546,6 +1568,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
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
|
||||
@@ -1799,6 +1867,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
|
||||
|
||||
> ⚠️ **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.
|
||||
@@ -1837,8 +1939,6 @@ endpoints:
|
||||
|
||||
#### Configuring SIGNL4 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.
|
||||
|
||||
SIGNL4 is a mobile alerting and incident management service that sends critical alerts to team members via mobile push, SMS, voice calls, and email.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
@@ -1867,49 +1967,16 @@ 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
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.slack.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
|
||||
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -2127,7 +2194,7 @@ Here's an example of what the notifications look like:
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
|
||||
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
|
||||
| `alerting.telegram.id` | Telegram User 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.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
@@ -2246,14 +2313,14 @@ 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.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.webex` | Configuration for alerts of type `webex` | `{}` |
|
||||
| `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.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.webex.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.webex.overrides[].*` | See `alerting.webex.*` parameters | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.webex` | Configuration for alerts of type `webex` | `{}` |
|
||||
| `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.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.webex.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.webex.overrides[].*` | See `alerting.webex.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -2305,7 +2372,7 @@ endpoints:
|
||||
#### Configuring Zulip alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.zulip` | Configuration for alerts of type `zulip` | `{}` |
|
||||
| `alerting.zulip` | Configuration for alerts of type `zulip` | `{}` |
|
||||
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
|
||||
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
|
||||
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
|
||||
@@ -2361,7 +2428,8 @@ Furthermore, you may use the following placeholders in the body (`alerting.custo
|
||||
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
|
||||
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
|
||||
- `[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
|
||||
`[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
|
||||
@@ -2579,6 +2647,7 @@ security:
|
||||
| `security.oidc.client-secret` | Client secret | Required `""` |
|
||||
| `security.oidc.scopes` | Scopes to request. The only scope you need is `openid`. | Required `[]` |
|
||||
| `security.oidc.allowed-subjects` | List of subjects to allow. If empty, all subjects are allowed. | `[]` |
|
||||
| `security.oidc.session-ttl` | Session time-to-live (e.g. `8h`, `1h30m`, `2h`). | `8h` |
|
||||
|
||||
```yaml
|
||||
security:
|
||||
@@ -2590,6 +2659,8 @@ security:
|
||||
scopes: ["openid"]
|
||||
# You may optionally specify a list of allowed subjects. If this is not specified, all subjects will be allowed.
|
||||
#allowed-subjects: ["johndoe@example.com"]
|
||||
# You may optionally specify a session time-to-live. If this is not specified, defaults to 8 hours.
|
||||
#session-ttl: 8h
|
||||
```
|
||||
|
||||
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).
|
||||
@@ -2619,6 +2690,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_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_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 |
|
||||
|
||||
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
|
||||
@@ -2696,24 +2768,24 @@ Many examples can be found in the [.examples](.examples) folder, but this sectio
|
||||
### Docker
|
||||
To run Gatus locally with Docker:
|
||||
```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
|
||||
creating a configuration file, we'll call it `config.yaml` for this example, and running the following
|
||||
command:
|
||||
```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.:
|
||||
```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:
|
||||
```console
|
||||
docker build . -t twinproduction/gatus
|
||||
docker build . -t ghcr.io/twin/gatus:stable
|
||||
```
|
||||
|
||||
|
||||
@@ -2733,8 +2805,10 @@ To get more details, please check [chart's configuration](https://github.com/Twi
|
||||
|
||||
|
||||
### 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
|
||||
```console
|
||||
@@ -2778,7 +2852,7 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
|
||||
|
||||
|
||||
### Recommended interval
|
||||
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus limits the number of
|
||||
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus limits the number of
|
||||
endpoints/suites that can be evaluated at the same time.
|
||||
In other words, even if you have multiple endpoints with the same interval, they are not guaranteed to run at the same time.
|
||||
|
||||
@@ -2874,20 +2948,59 @@ This works for SCTP based application.
|
||||
|
||||
|
||||
### 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
|
||||
endpoints:
|
||||
- name: example
|
||||
url: "wss://example.com/"
|
||||
url: "wss://echo.websocket.org/"
|
||||
body: "status"
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[BODY].result >= 0"
|
||||
- "[BODY] == pat(*served by*)"
|
||||
```
|
||||
|
||||
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
|
||||
shows whether the connection was successfully established. You can use Go template
|
||||
syntax. The functions LocalAddr and RandomString with a length can be used.
|
||||
shows whether the connection was successfully established. You can use Go template
|
||||
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
|
||||
@@ -2904,9 +3017,12 @@ endpoints:
|
||||
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://`.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
|
||||
@@ -2939,12 +3055,13 @@ endpoints:
|
||||
password: "password"
|
||||
body: |
|
||||
{
|
||||
"command": "uptime"
|
||||
"command": "echo '{\"memory\": {\"used\": 512}}'"
|
||||
}
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[STATUS] == 0"
|
||||
- "[BODY].memory.used > 500"
|
||||
```
|
||||
|
||||
you can also use no authentication to monitor the endpoint by not specifying the username
|
||||
@@ -2967,6 +3084,7 @@ endpoints:
|
||||
The following placeholders are supported for endpoints of type SSH:
|
||||
- `[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)
|
||||
- `[BODY]` resolves to the stdout output of the command executed on the remote 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
|
||||
|
||||
@@ -3020,7 +3138,7 @@ endpoints:
|
||||
- "[CERTIFICATE_EXPIRATION] > 240h"
|
||||
```
|
||||
|
||||
> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to use RDAP, or as a fallback, send a request to the official IANA WHOIS service
|
||||
> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to use RDAP, or as a fallback, send a request to the official IANA WHOIS service
|
||||
> [through a library](https://github.com/TwiN/whois) and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`).
|
||||
> To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from
|
||||
> using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`.
|
||||
@@ -3049,7 +3167,7 @@ concurrency: 0
|
||||
|
||||
**Use cases for higher concurrency:**
|
||||
- You have a large number of endpoints to monitor
|
||||
- You want to monitor endpoints at very short intervals (< 5s)
|
||||
- You want to monitor endpoints at very short intervals (< 5s)
|
||||
- You're using Gatus for load testing scenarios
|
||||
|
||||
**Legacy configuration:**
|
||||
@@ -3133,7 +3251,7 @@ ui:
|
||||
default-sort-by: group
|
||||
```
|
||||
Note that if a user has already sorted the dashboard by a different field, the default sort will not be applied unless the user
|
||||
clears their browser's localstorage.
|
||||
clears their browser's localstorage.
|
||||
|
||||
|
||||
### Exposing Gatus on a custom path
|
||||
@@ -3239,7 +3357,7 @@ The path to generate a badge is the following:
|
||||
```
|
||||
Where:
|
||||
- `{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`,
|
||||
the URL would look like this:
|
||||
@@ -3265,7 +3383,7 @@ The path to generate a badge is the following:
|
||||
/api/v1/endpoints/{key}/health/badge.svg
|
||||
```
|
||||
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`,
|
||||
the URL would look like this:
|
||||
@@ -3282,7 +3400,7 @@ The path to generate a badge is the following:
|
||||
/api/v1/endpoints/{key}/health/badge.shields
|
||||
```
|
||||
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`,
|
||||
the URL would look like this:
|
||||
@@ -3305,7 +3423,7 @@ The endpoint to generate a badge is the following:
|
||||
```
|
||||
Where:
|
||||
- `{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)
|
||||

|
||||
@@ -3318,7 +3436,7 @@ The endpoint to generate a response time chart is the following:
|
||||
```
|
||||
Where:
|
||||
- `{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
|
||||
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
|
||||
@@ -3376,7 +3494,7 @@ The path to get raw uptime data for an endpoint is:
|
||||
```
|
||||
Where:
|
||||
- `{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:
|
||||
```
|
||||
@@ -3390,7 +3508,7 @@ The path to get raw response time data for an endpoint is:
|
||||
```
|
||||
Where:
|
||||
- `{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:
|
||||
```
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
var (
|
||||
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
|
||||
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
||||
|
||||
ErrAlertWithInvalidMinimumReminderInterval = errors.New("minimum-reminder-interval must be either omitted or be at least 5m")
|
||||
)
|
||||
|
||||
// Alert is endpoint.Endpoint's alert configuration
|
||||
@@ -78,6 +80,9 @@ func (alert *Alert) ValidateAndSetDefaults() error {
|
||||
if alert.SuccessThreshold <= 0 {
|
||||
alert.SuccessThreshold = 2
|
||||
}
|
||||
if alert.MinimumReminderInterval != 0 && alert.MinimumReminderInterval < 5*time.Minute {
|
||||
return ErrAlertWithInvalidMinimumReminderInterval
|
||||
}
|
||||
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
|
||||
return ErrAlertWithInvalidDescription
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package alert
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||
@@ -36,6 +37,61 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "valid-minimum-reminder-interval-0",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 0,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "valid-minimum-reminder-interval-5m",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 5 * time.Minute,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "valid-minimum-reminder-interval-10m",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 10 * time.Minute,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "invalid-minimum-reminder-interval-1m",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 1 * time.Minute,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "invalid-minimum-reminder-interval-1s",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 1 * time.Second,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
|
||||
@@ -47,9 +47,6 @@ const (
|
||||
// TypeIncidentIO is the Type for the incident-io alerting provider
|
||||
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 Type = "line"
|
||||
|
||||
@@ -65,6 +62,9 @@ const (
|
||||
// TypeNewRelic is the Type for the newrelic alerting provider
|
||||
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 Type = "ntfy"
|
||||
|
||||
|
||||
+6
-7
@@ -20,11 +20,11 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"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/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"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/ntfy"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||
@@ -66,7 +66,6 @@ type Config struct {
|
||||
// Email is the configuration for the email alerting provider
|
||||
Email *email.AlertProvider `yaml:"email,omitempty"`
|
||||
|
||||
|
||||
// GitHub is the configuration for the github alerting provider
|
||||
GitHub *github.AlertProvider `yaml:"github,omitempty"`
|
||||
|
||||
@@ -81,22 +80,19 @@ type Config struct {
|
||||
|
||||
// Gotify is the configuration for the gotify alerting provider
|
||||
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
||||
|
||||
|
||||
// HomeAssistant is the configuration for the homeassistant alerting provider
|
||||
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
|
||||
|
||||
// IFTTT is the configuration for the ifttt alerting provider
|
||||
IFTTT *ifttt.AlertProvider `yaml:"ifttt,omitempty"`
|
||||
|
||||
|
||||
// Ilert is the configuration for the ilert alerting provider
|
||||
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
|
||||
|
||||
// IncidentIO is the configuration for the incident-io alerting provider
|
||||
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 *line.AlertProvider `yaml:"line,omitempty"`
|
||||
|
||||
@@ -112,6 +108,9 @@ type Config struct {
|
||||
// NewRelic is the configuration for the newrelic alerting provider
|
||||
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 *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package awsses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/ses"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/ses"
|
||||
"github.com/aws/aws-sdk-go-v2/service/ses/types"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -102,63 +102,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
awsSession, err := provider.createSession(cfg)
|
||||
ctx := context.Background()
|
||||
svc, err := provider.createClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc := ses.New(awsSession)
|
||||
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||
emails := strings.Split(cfg.To, ",")
|
||||
|
||||
input := &ses.SendEmailInput{
|
||||
Destination: &ses.Destination{
|
||||
ToAddresses: aws.StringSlice(emails),
|
||||
Destination: &types.Destination{
|
||||
ToAddresses: emails,
|
||||
},
|
||||
Message: &ses.Message{
|
||||
Body: &ses.Body{
|
||||
Text: &ses.Content{
|
||||
Message: &types.Message{
|
||||
Body: &types.Body{
|
||||
Text: &types.Content{
|
||||
Charset: aws.String(CharSet),
|
||||
Data: aws.String(body),
|
||||
},
|
||||
},
|
||||
Subject: &ses.Content{
|
||||
Subject: &types.Content{
|
||||
Charset: aws.String(CharSet),
|
||||
Data: aws.String(subject),
|
||||
},
|
||||
},
|
||||
Source: aws.String(cfg.From),
|
||||
}
|
||||
if _, err = svc.SendEmail(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())
|
||||
}
|
||||
|
||||
if _, err = svc.SendEmail(ctx, input); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
|
||||
awsConfig := &aws.Config{
|
||||
Region: aws.String(cfg.Region),
|
||||
func (provider *AlertProvider) createClient(ctx context.Context, cfg *Config) (*ses.Client, error) {
|
||||
var opts []func(*config.LoadOptions) error
|
||||
if len(cfg.Region) > 0 {
|
||||
opts = append(opts, config.WithRegion(cfg.Region))
|
||||
}
|
||||
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
|
||||
|
||||
@@ -111,6 +111,25 @@ func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoi
|
||||
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
|
||||
body = strings.ReplaceAll(body, "[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 {
|
||||
body = strings.ReplaceAll(body, "[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) {
|
||||
alertProvider := &AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
|
||||
@@ -20,8 +20,9 @@ var (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
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 {
|
||||
@@ -38,6 +39,9 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
if len(override.MessageContent) > 0 {
|
||||
cfg.MessageContent = override.MessageContent
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
body := Body{
|
||||
Content: "",
|
||||
Content: cfg.MessageContent,
|
||||
Embeds: []Embed{
|
||||
{
|
||||
Title: title,
|
||||
|
||||
@@ -134,6 +134,16 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}),
|
||||
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 {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -200,6 +210,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
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}]}",
|
||||
},
|
||||
{
|
||||
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 {
|
||||
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"}},
|
||||
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 {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -323,6 +387,9 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
if got.WebhookURL != scenario.ExpectedOutput.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
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
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[:])
|
||||
}
|
||||
@@ -153,27 +153,44 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
} else {
|
||||
prefix = "🔴"
|
||||
}
|
||||
// No need for \n since incident.io trims it anyways.
|
||||
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
|
||||
}
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
message += " with the following description: " + alert.GetDescription()
|
||||
}
|
||||
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)
|
||||
body, _ = json.Marshal(Body{
|
||||
// Merge metadata: cfg.Metadata + ep.ExtraLabels (if present)
|
||||
mergedMetadata := map[string]interface{}{}
|
||||
// Copy cfg.Metadata
|
||||
for k, v := range cfg.Metadata {
|
||||
mergedMetadata[k] = v
|
||||
}
|
||||
// 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,
|
||||
Title: "Gatus: " + ep.DisplayName(),
|
||||
Status: status,
|
||||
DeduplicationKey: alert.ResolveKey,
|
||||
Description: message,
|
||||
SourceURL: cfg.SourceURL,
|
||||
Metadata: cfg.Metadata,
|
||||
Metadata: mergedMetadata,
|
||||
})
|
||||
fmt.Printf("%v", string(body))
|
||||
return body
|
||||
|
||||
}
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
|
||||
@@ -183,39 +183,63 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
secondDescription := "description-2"
|
||||
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedAlertSourceID string
|
||||
ExpectedStatus string
|
||||
ExpectedTitle string
|
||||
ExpectedDescription string
|
||||
ExpectedSourceURL string
|
||||
ExpectedMetadata map[string]interface{}
|
||||
ShouldHaveDeduplicationKey bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
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 "}`,
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
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",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
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 "}`,
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
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",
|
||||
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},
|
||||
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"}}`,
|
||||
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"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
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",
|
||||
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},
|
||||
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 "}`,
|
||||
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"}}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
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,
|
||||
)
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
|
||||
// Parse the JSON body
|
||||
var parsedBody Body
|
||||
if err := json.Unmarshal(body, &parsedBody); err != nil {
|
||||
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 (
|
||||
"bytes"
|
||||
@@ -15,50 +15,36 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotSet = errors.New("project not set")
|
||||
ErrChannelIDNotSet = errors.New("channel-id not set")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Project string `yaml:"project"` // Project name
|
||||
ChannelID string `yaml:"channel-id"` // Chat Channel ID
|
||||
Token string `yaml:"token"` // Bearer Token
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.Project) == 0 {
|
||||
return ErrProjectNotSet
|
||||
}
|
||||
if len(cfg.ChannelID) == 0 {
|
||||
return ErrChannelIDNotSet
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.Project) > 0 {
|
||||
cfg.Project = override.Project
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.ChannelID) > 0 {
|
||||
cfg.ChannelID = override.ChannelID
|
||||
}
|
||||
if len(override.Token) > 0 {
|
||||
cfg.Token = override.Token
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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"`
|
||||
}
|
||||
@@ -90,13 +76,11 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
return err
|
||||
}
|
||||
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, url, buffer)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -110,78 +94,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Channel string `json:"channel"`
|
||||
Content Content `json:"content"`
|
||||
Title string `json:"title"`
|
||||
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 {
|
||||
ClassName string `json:"className"`
|
||||
Style string `json:"style"`
|
||||
Sections []Section `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
ClassName string `json:"className"`
|
||||
Elements []Element `json:"elements"`
|
||||
Header string `json:"header"`
|
||||
}
|
||||
|
||||
type Element struct {
|
||||
ClassName string `json:"className"`
|
||||
Accessory Accessory `json:"accessory"`
|
||||
Style string `json:"style"`
|
||||
Size string `json:"size"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Accessory struct {
|
||||
ClassName string `json:"className"`
|
||||
Icon Icon `json:"icon"`
|
||||
Style string `json:"style"`
|
||||
}
|
||||
|
||||
type Icon struct {
|
||||
Icon string `json:"icon"`
|
||||
type ConditionResult struct {
|
||||
Condition string `json:"condition"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
body := Body{
|
||||
Channel: "id:" + cfg.ChannelID,
|
||||
Content: Content{
|
||||
ClassName: "ChatMessage.Block",
|
||||
Sections: []Section{{
|
||||
ClassName: "MessageSection",
|
||||
Elements: []Element{},
|
||||
}},
|
||||
},
|
||||
}
|
||||
var message string
|
||||
if resolved {
|
||||
body.Content.Style = "SUCCESS"
|
||||
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
body.Content.Style = "WARNING"
|
||||
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
message = 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 {
|
||||
icon := "warning"
|
||||
style := "WARNING"
|
||||
if conditionResult.Success {
|
||||
icon = "success"
|
||||
style = "SUCCESS"
|
||||
}
|
||||
body.Content.Sections[0].Elements = append(body.Content.Sections[0].Elements, Element{
|
||||
ClassName: "MessageText",
|
||||
Accessory: Accessory{
|
||||
ClassName: "MessageIcon",
|
||||
Icon: Icon{Icon: icon},
|
||||
Style: style,
|
||||
},
|
||||
Style: style,
|
||||
Size: "REGULAR",
|
||||
Content: conditionResult.Condition,
|
||||
conditionResults = append(conditionResults, ConditionResult{
|
||||
Condition: conditionResult.Condition,
|
||||
Success: conditionResult.Success,
|
||||
})
|
||||
}
|
||||
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)
|
||||
return bodyAsJSON
|
||||
}
|
||||
+117
-71
@@ -1,4 +1,4 @@
|
||||
package jetbrainsspace
|
||||
package n8n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
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 {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
@@ -24,10 +24,9 @@ func TestAlertProvider_Validate(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
DefaultConfig: Config{Project: "foobar"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{ChannelID: "http://example.com"},
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
@@ -36,26 +35,21 @@ func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
DefaultConfig: Config{Project: "foobar"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{ChannelID: ""},
|
||||
Config: Config{WebhookURL: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
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{
|
||||
DefaultConfig: Config{
|
||||
Project: "foo",
|
||||
ChannelID: "bar",
|
||||
Token: "baz",
|
||||
},
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{ChannelID: "foobar"},
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
@@ -79,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -89,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
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},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -99,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
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},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -109,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
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},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -151,45 +145,94 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Endpoint endpoint.Endpoint
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
ExpectedBody Body
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
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"}]}}`,
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
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",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
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"}]}}`,
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group", URL: "https://example.org"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
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",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
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"}]}}`,
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
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",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
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"}]}}`,
|
||||
Name: "resolved-with-custom-title",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "Custom Title"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
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 {
|
||||
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(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
cfg,
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -200,13 +243,22 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
var actualBody Body
|
||||
if err := json.Unmarshal(body, &actualBody); err != nil {
|
||||
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",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
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",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
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",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "group-channel"},
|
||||
Config: Config{WebhookURL: "http://example01.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
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",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "group-channel"},
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
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",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "group-channel"},
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
|
||||
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
@@ -300,14 +352,8 @@ func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
||||
}
|
||||
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)
|
||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"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/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"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/ntfy"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||
@@ -105,11 +105,11 @@ var (
|
||||
_ AlertProvider = (*ifttt.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ilert.AlertProvider)(nil)
|
||||
_ AlertProvider = (*incidentio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
||||
_ AlertProvider = (*line.AlertProvider)(nil)
|
||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*n8n.AlertProvider)(nil)
|
||||
_ AlertProvider = (*newrelic.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ntfy.AlertProvider)(nil)
|
||||
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
|
||||
@@ -146,11 +146,11 @@ var (
|
||||
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
|
||||
_ Config[ilert.Config] = (*ilert.Config)(nil)
|
||||
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
|
||||
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
|
||||
_ Config[line.Config] = (*line.Config)(nil)
|
||||
_ Config[matrix.Config] = (*matrix.Config)(nil)
|
||||
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
|
||||
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
|
||||
_ Config[n8n.Config] = (*n8n.Config)(nil)
|
||||
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
|
||||
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
|
||||
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
|
||||
|
||||
@@ -20,7 +20,8 @@ var (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
@@ -34,6 +35,9 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
@@ -73,7 +77,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -111,7 +115,7 @@ type Field struct {
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(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 {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
@@ -138,13 +142,16 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
Text: "",
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Title: ":helmet_with_white_cross: Gatus",
|
||||
Title: cfg.Title,
|
||||
Text: message + description,
|
||||
Short: false,
|
||||
Color: color,
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(body.Attachments[0].Title) == 0 {
|
||||
body.Attachments[0].Title = ":helmet_with_white_cross: Gatus"
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||
Title: "Condition results",
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -158,7 +158,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -175,7 +175,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
@@ -183,12 +183,20 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group-and-custom-title",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "custom title"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\"custom title\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -199,7 +207,12 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
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(
|
||||
cfg,
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
|
||||
@@ -166,7 +166,10 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
Value: conditionResult.Condition,
|
||||
})
|
||||
}
|
||||
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "**Description**: " + alertDescription
|
||||
}
|
||||
cardContent := AdaptiveCardBody{
|
||||
Type: "AdaptiveCard",
|
||||
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
|
||||
@@ -190,6 +193,11 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
Text: message,
|
||||
Wrap: true,
|
||||
},
|
||||
{
|
||||
Type: "TextBlock",
|
||||
Text: description,
|
||||
Wrap: true,
|
||||
},
|
||||
{
|
||||
Type: "FactSet",
|
||||
Facts: facts,
|
||||
|
||||
@@ -152,14 +152,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-1\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-no-conditions",
|
||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -147,7 +147,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
}
|
||||
var text string
|
||||
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 {
|
||||
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) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
descriptionWithLink := "[link](https://example.org/)"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
@@ -137,14 +138,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
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",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\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",
|
||||
@@ -152,14 +153,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
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",
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123", TopicID: "7"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"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 {
|
||||
|
||||
@@ -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/badge.svg", ResponseTimeBadge(cfg))
|
||||
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
|
||||
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
||||
// SPA
|
||||
|
||||
@@ -126,3 +126,63 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,11 +68,20 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
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)
|
||||
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
|
||||
if !cfg.Maintenance.IsUnderMaintenance() {
|
||||
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
|
||||
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
|
||||
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
||||
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||
} else {
|
||||
logr.Debug("[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window")
|
||||
}
|
||||
if cfg.Metrics {
|
||||
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
|
||||
|
||||
+9
-2
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
@@ -25,7 +26,10 @@ func UptimeRaw(c *fiber.Ctx) error {
|
||||
default:
|
||||
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())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
@@ -57,7 +61,10 @@ func ResponseTimeRaw(c *fiber.Ctx) error {
|
||||
default:
|
||||
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())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
|
||||
+35
-15
@@ -1,17 +1,19 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -301,7 +303,7 @@ func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
@@ -309,26 +311,30 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
|
||||
var b Body
|
||||
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
|
||||
if err := json.Unmarshal([]byte(body), &b); err != nil {
|
||||
return false, 0, err
|
||||
return false, 0, nil, err
|
||||
}
|
||||
sess, err := sshClient.NewSession()
|
||||
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)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
return false, 0, nil, err
|
||||
}
|
||||
defer sess.Close()
|
||||
err = sess.Wait()
|
||||
output := stdout.Bytes()
|
||||
if err == nil {
|
||||
return true, 0, nil
|
||||
return true, 0, output, nil
|
||||
}
|
||||
var exitErr *ssh.ExitError
|
||||
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
|
||||
@@ -338,12 +344,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
pinger := ping.New(address)
|
||||
pinger.Count = 1
|
||||
pinger.Timeout = config.Timeout
|
||||
// Set the pinger's privileged mode to true for every GOOS except darwin
|
||||
// See https://github.com/TwiN/gatus/issues/132
|
||||
//
|
||||
// Note that for this to work on Linux, Gatus must run with sudo privileges.
|
||||
// See https://github.com/prometheus-community/pro-bing#linux
|
||||
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
||||
pinger.SetPrivileged(ShouldRunPingerAsPrivileged())
|
||||
pinger.SetNetwork(config.Network)
|
||||
err := pinger.Run()
|
||||
if err != nil {
|
||||
@@ -359,6 +360,25 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
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
|
||||
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
|
||||
const (
|
||||
@@ -516,4 +536,4 @@ func reverseNameForIP(ipStr string) (string, error) {
|
||||
nibbles[i], nibbles[j] = nibbles[j], nibbles[i]
|
||||
}
|
||||
return strings.Join(nibbles, ".") + ".ip6.arpa.", nil
|
||||
}
|
||||
}
|
||||
|
||||
+38
-16
@@ -6,6 +6,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -129,10 +131,37 @@ 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) {
|
||||
type args struct {
|
||||
address string
|
||||
insecure bool
|
||||
address string
|
||||
insecure bool
|
||||
dnsresolver string
|
||||
}
|
||||
tests := []struct {
|
||||
@@ -168,7 +197,7 @@ func TestCanPerformStartTLS(t *testing.T) {
|
||||
{
|
||||
name: "dns resolver",
|
||||
args: args{
|
||||
address: "smtp.gmail.com:587",
|
||||
address: "smtp.gmail.com:587",
|
||||
dnsresolver: "tcp://1.1.1.1:53",
|
||||
},
|
||||
wantConnected: true,
|
||||
@@ -340,7 +369,7 @@ func TestQueryWebSocket(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTlsRenegotiation(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg TLSConfig
|
||||
expectedConfig tls.RenegotiationSupport
|
||||
@@ -371,12 +400,12 @@ func TestTlsRenegotiation(t *testing.T) {
|
||||
expectedConfig: tls.RenegotiateNever,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
tls := &tls.Config{}
|
||||
tlsConfig := configureTLS(tls, test.cfg)
|
||||
if tlsConfig.Renegotiation != test.expectedConfig {
|
||||
t.Errorf("expected tls renegotiation to be %v, but got %v", test.expectedConfig, tls.Renegotiation)
|
||||
tlsConfig := configureTLS(tls, scenario.cfg)
|
||||
if tlsConfig.Renegotiation != scenario.expectedConfig {
|
||||
t.Errorf("expected tls renegotiation to be %v, but got %v", scenario.expectedConfig, tls.Renegotiation)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -513,14 +542,11 @@ func TestQueryDNS(t *testing.T) {
|
||||
|
||||
func TestCheckSSHBanner(t *testing.T) {
|
||||
cfg := &Config{Timeout: 3}
|
||||
|
||||
t.Run("no-auth-ssh", func(t *testing.T) {
|
||||
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected: error != nil, got: %v ", err)
|
||||
}
|
||||
|
||||
if connected == false {
|
||||
t.Errorf("Expected: connected == true, got: %v", connected)
|
||||
}
|
||||
@@ -528,14 +554,11 @@ func TestCheckSSHBanner(t *testing.T) {
|
||||
t.Errorf("Expected: 0, got: %v", status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid-address", func(t *testing.T) {
|
||||
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
|
||||
|
||||
if err == nil {
|
||||
t.Errorf("Expected: error, got: %v ", err)
|
||||
}
|
||||
|
||||
if connected != false {
|
||||
t.Errorf("Expected: connected == false, got: %v", connected)
|
||||
}
|
||||
@@ -543,5 +566,4 @@ func TestCheckSSHBanner(t *testing.T) {
|
||||
t.Errorf("Expected: 1, got: %v", status)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
+17
-2
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
|
||||
"github.com/TwiN/logr"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
@@ -69,13 +70,19 @@ type Config struct {
|
||||
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
|
||||
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`
|
||||
|
||||
httpClient *http.Client
|
||||
|
||||
// Network (ip, ip4 or ip6) for the ICMP client
|
||||
Network string `yaml:"network"`
|
||||
|
||||
// TLS configuration (optional)
|
||||
TLS *TLSConfig `yaml:"tls,omitempty"`
|
||||
|
||||
// Tunnel is the name of the SSH tunnel to use for the client
|
||||
Tunnel string `yaml:"tunnel,omitempty"`
|
||||
|
||||
// ResolvedTunnel is the resolved SSH tunnel for this specific Config
|
||||
ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"`
|
||||
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
|
||||
@@ -265,6 +272,14 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
} else if c.HasIAPConfig() {
|
||||
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
|
||||
}
|
||||
if c.ResolvedTunnel != nil {
|
||||
// Use SSH tunnel dialer
|
||||
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return c.ResolvedTunnel.Dial(network, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.httpClient
|
||||
}
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+107
-49
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting"
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/announcement"
|
||||
"github.com/TwiN/gatus/v5/config/connectivity"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/config/remote"
|
||||
"github.com/TwiN/gatus/v5/config/suite"
|
||||
"github.com/TwiN/gatus/v5/config/tunneling"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
@@ -114,6 +116,9 @@ type Config struct {
|
||||
// Connectivity is the configuration for connectivity
|
||||
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
|
||||
|
||||
// Tunneling is the configuration for SSH tunneling
|
||||
Tunneling *tunneling.Config `yaml:"tunneling,omitempty"`
|
||||
|
||||
// Announcements is the list of system-wide announcements
|
||||
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
|
||||
|
||||
@@ -295,55 +300,111 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
|
||||
}
|
||||
// XXX: End of v6.0.0 removals
|
||||
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
|
||||
if err := validateSecurityConfig(config); err != nil {
|
||||
ValidateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
|
||||
if err := ValidateSecurityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateEndpointsConfig(config); err != nil {
|
||||
if err := ValidateEndpointsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateWebConfig(config); err != nil {
|
||||
if err := ValidateWebConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateUIConfig(config); err != nil {
|
||||
if err := ValidateUIConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateMaintenanceConfig(config); err != nil {
|
||||
if err := ValidateMaintenanceConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateStorageConfig(config); err != nil {
|
||||
if err := ValidateStorageConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRemoteConfig(config); err != nil {
|
||||
if err := ValidateRemoteConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateConnectivityConfig(config); err != nil {
|
||||
if err := ValidateConnectivityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAnnouncementsConfig(config); err != nil {
|
||||
if err := ValidateTunnelingConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSuitesConfig(config); err != nil {
|
||||
if err := ValidateAnnouncementsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateUniqueKeys(config); err != nil {
|
||||
if err := ValidateSuitesConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validateAndSetConcurrencyDefaults(config)
|
||||
if err := ValidateUniqueKeys(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ValidateAndSetConcurrencyDefaults(config)
|
||||
// Cross-config changes
|
||||
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func validateConnectivityConfig(config *Config) error {
|
||||
func ValidateConnectivityConfig(config *Config) error {
|
||||
if config.Connectivity != nil {
|
||||
return config.Connectivity.ValidateAndSetDefaults()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnnouncementsConfig(config *Config) error {
|
||||
// ValidateTunnelingConfig validates the tunneling configuration and resolves tunnel references
|
||||
// NOTE: This must be called after ValidateEndpointsConfig and ValidateSuitesConfig
|
||||
// because it resolves tunnel references in endpoint and suite client configurations
|
||||
func ValidateTunnelingConfig(config *Config) error {
|
||||
if config.Tunneling != nil {
|
||||
if err := config.Tunneling.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve tunnel references in all endpoints
|
||||
for _, ep := range config.Endpoints {
|
||||
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
|
||||
return fmt.Errorf("endpoint '%s': %w", ep.Key(), err)
|
||||
}
|
||||
}
|
||||
// Resolve tunnel references in suite endpoints
|
||||
for _, s := range config.Suites {
|
||||
for _, ep := range s.Endpoints {
|
||||
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
|
||||
return fmt.Errorf("suite '%s' endpoint '%s': %w", s.Key(), ep.Key(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Add tunnel support for alert providers when needed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTunnelForClientConfig resolves tunnel references in a client configuration
|
||||
func resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) error {
|
||||
if clientConfig == nil || clientConfig.Tunnel == "" {
|
||||
return nil
|
||||
}
|
||||
// Validate tunnel name
|
||||
tunnelName := strings.TrimSpace(clientConfig.Tunnel)
|
||||
if tunnelName == "" {
|
||||
return fmt.Errorf("tunnel name cannot be empty")
|
||||
}
|
||||
if config.Tunneling == nil {
|
||||
return fmt.Errorf("tunnel '%s' referenced but no tunneling configuration defined", tunnelName)
|
||||
}
|
||||
_, exists := config.Tunneling.Tunnels[tunnelName]
|
||||
if !exists {
|
||||
return fmt.Errorf("tunnel '%s' not found in tunneling configuration", tunnelName)
|
||||
}
|
||||
// Get or create the SSH tunnel instance and store it directly in client config
|
||||
tunnel, err := config.Tunneling.GetTunnel(tunnelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tunnel '%s': %w", tunnelName, err)
|
||||
}
|
||||
clientConfig.ResolvedTunnel = tunnel
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateAnnouncementsConfig(config *Config) error {
|
||||
if config.Announcements != nil {
|
||||
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
|
||||
return err
|
||||
@@ -354,7 +415,7 @@ func validateAnnouncementsConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRemoteConfig(config *Config) error {
|
||||
func ValidateRemoteConfig(config *Config) error {
|
||||
if config.Remote != nil {
|
||||
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
@@ -363,7 +424,7 @@ func validateRemoteConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStorageConfig(config *Config) error {
|
||||
func ValidateStorageConfig(config *Config) error {
|
||||
if config.Storage == nil {
|
||||
config.Storage = &storage.Config{
|
||||
Type: storage.TypeMemory,
|
||||
@@ -378,7 +439,7 @@ func validateStorageConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMaintenanceConfig(config *Config) error {
|
||||
func ValidateMaintenanceConfig(config *Config) error {
|
||||
if config.Maintenance == nil {
|
||||
config.Maintenance = maintenance.GetDefaultConfig()
|
||||
} else {
|
||||
@@ -389,7 +450,7 @@ func validateMaintenanceConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUIConfig(config *Config) error {
|
||||
func ValidateUIConfig(config *Config) error {
|
||||
if config.UI == nil {
|
||||
config.UI = ui.GetDefaultConfig()
|
||||
} else {
|
||||
@@ -400,7 +461,7 @@ func validateUIConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWebConfig(config *Config) error {
|
||||
func ValidateWebConfig(config *Config) error {
|
||||
if config.Web == nil {
|
||||
config.Web = web.GetDefaultConfig()
|
||||
} else {
|
||||
@@ -409,11 +470,11 @@ func validateWebConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEndpointsConfig(config *Config) error {
|
||||
func ValidateEndpointsConfig(config *Config) error {
|
||||
duplicateValidationMap := make(map[string]bool)
|
||||
// Validate 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] {
|
||||
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
|
||||
} else {
|
||||
@@ -423,10 +484,10 @@ func validateEndpointsConfig(config *Config) error {
|
||||
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
|
||||
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] {
|
||||
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
|
||||
} else {
|
||||
@@ -436,13 +497,13 @@ func validateEndpointsConfig(config *Config) error {
|
||||
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
|
||||
}
|
||||
|
||||
func validateSuitesConfig(config *Config) error {
|
||||
func ValidateSuitesConfig(config *Config) error {
|
||||
if config.Suites == nil || len(config.Suites) == 0 {
|
||||
logr.Info("[config.validateSuitesConfig] No suites configured")
|
||||
logr.Info("[config.ValidateSuitesConfig] No suites configured")
|
||||
return nil
|
||||
}
|
||||
suiteNames := make(map[string]bool)
|
||||
@@ -471,11 +532,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
|
||||
}
|
||||
|
||||
func validateUniqueKeys(config *Config) error {
|
||||
func ValidateUniqueKeys(config *Config) error {
|
||||
keyMap := make(map[string]string) // key -> description for error messages
|
||||
// Check all endpoints
|
||||
for _, ep := range config.Endpoints {
|
||||
@@ -512,26 +573,23 @@ func validateUniqueKeys(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityConfig(config *Config) error {
|
||||
func ValidateSecurityConfig(config *Config) error {
|
||||
if config.Security != nil {
|
||||
if config.Security.IsValid() {
|
||||
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.
|
||||
if !config.Security.ValidateAndSetDefaults() {
|
||||
logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated")
|
||||
return ErrInvalidSecurityConfig
|
||||
}
|
||||
}
|
||||
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
|
||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
|
||||
// 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 {
|
||||
logr.Info("[config.validateAlertingConfig] Alerting is not configured")
|
||||
logr.Info("[config.ValidateAlertingConfig] Alerting is not configured")
|
||||
return
|
||||
}
|
||||
alertTypes := []alert.Type{
|
||||
@@ -549,11 +607,11 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
alert.TypeIFTTT,
|
||||
alert.TypeIlert,
|
||||
alert.TypeIncidentIO,
|
||||
alert.TypeJetBrainsSpace,
|
||||
alert.TypeLine,
|
||||
alert.TypeMatrix,
|
||||
alert.TypeMattermost,
|
||||
alert.TypeMessagebird,
|
||||
alert.TypeN8N,
|
||||
alert.TypeNewRelic,
|
||||
alert.TypeNtfy,
|
||||
alert.TypeOpsgenie,
|
||||
@@ -586,12 +644,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
for _, ep := range endpoints {
|
||||
for alertIndex, endpointAlert := range ep.Alerts {
|
||||
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)
|
||||
// Validate the endpoint alert's overrides, if applicable
|
||||
if len(endpointAlert.ProviderOverride) > 0 {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,12 +658,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
for _, ee := range externalEndpoints {
|
||||
for alertIndex, endpointAlert := range ee.Alerts {
|
||||
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)
|
||||
// Validate the endpoint alert's overrides, if applicable
|
||||
if len(endpointAlert.ProviderOverride) > 0 {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -614,7 +672,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
}
|
||||
validProviders = append(validProviders, alertType)
|
||||
} 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)
|
||||
alertingConfig.SetAlertingProviderToNil(alertProvider)
|
||||
}
|
||||
@@ -622,19 +680,19 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
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 {
|
||||
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: 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 {
|
||||
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 {
|
||||
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
|
||||
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
|
||||
}
|
||||
}
|
||||
|
||||
+206
-65
@@ -25,7 +25,6 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"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/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
@@ -53,6 +52,9 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/suite"
|
||||
"github.com/TwiN/gatus/v5/config/tunneling"
|
||||
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -772,10 +774,6 @@ alerting:
|
||||
to: "+1-234-567-8901"
|
||||
teams:
|
||||
webhook-url: "http://example.com"
|
||||
jetbrainsspace:
|
||||
project: "foo"
|
||||
channel-id: "bar"
|
||||
token: "baz"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
@@ -798,7 +796,6 @@ endpoints:
|
||||
success-threshold: 15
|
||||
- type: teams
|
||||
- type: pushover
|
||||
- type: jetbrainsspace
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
@@ -825,8 +822,8 @@ endpoints:
|
||||
if config.Endpoints[0].Interval != 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 {
|
||||
t.Fatal("There should've been 10 alerts configured")
|
||||
if len(config.Endpoints[0].Alerts) != 9 {
|
||||
t.Fatal("There should've been 9 alerts configured")
|
||||
}
|
||||
|
||||
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
||||
@@ -933,12 +930,6 @@ endpoints:
|
||||
if !config.Endpoints[0].Alerts[8].IsEnabled() {
|
||||
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) {
|
||||
@@ -998,14 +989,6 @@ alerting:
|
||||
webhook-url: "http://example.com"
|
||||
default-alert:
|
||||
enabled: true
|
||||
jetbrainsspace:
|
||||
project: "foo"
|
||||
channel-id: "bar"
|
||||
token: "baz"
|
||||
default-alert:
|
||||
enabled: true
|
||||
failure-threshold: 5
|
||||
success-threshold: 3
|
||||
email:
|
||||
from: "from@example.com"
|
||||
username: "from@example.com"
|
||||
@@ -1044,7 +1027,6 @@ endpoints:
|
||||
- type: twilio
|
||||
- type: teams
|
||||
- type: pushover
|
||||
- type: jetbrainsspace
|
||||
- type: email
|
||||
- type: gotify
|
||||
conditions:
|
||||
@@ -1166,22 +1148,6 @@ endpoints:
|
||||
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 {
|
||||
t.Fatal("Email alerting config should've been valid")
|
||||
}
|
||||
@@ -1253,8 +1219,8 @@ endpoints:
|
||||
if config.Endpoints[0].Interval != 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 {
|
||||
t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts))
|
||||
if len(config.Endpoints[0].Alerts) != 11 {
|
||||
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 {
|
||||
@@ -1371,21 +1337,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)
|
||||
}
|
||||
|
||||
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].Type != alert.TypeEmail {
|
||||
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() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Endpoints[0].Alerts[9].FailureThreshold != 5 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[9].FailureThreshold)
|
||||
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", 3, config.Endpoints[0].Alerts[9].FailureThreshold)
|
||||
}
|
||||
if config.Endpoints[0].Alerts[9].SuccessThreshold != 3 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].SuccessThreshold)
|
||||
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", 2, config.Endpoints[0].Alerts[9].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Endpoints[0].Alerts[10].Type != alert.TypeEmail {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[10].Type)
|
||||
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.TypeGotify, config.Endpoints[0].Alerts[10].Type)
|
||||
}
|
||||
if !config.Endpoints[0].Alerts[10].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
@@ -1396,19 +1362,6 @@ endpoints:
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1850,7 +1803,7 @@ endpoints:
|
||||
if config.Security == nil {
|
||||
t.Fatal("config.Security shouldn't have been nil")
|
||||
}
|
||||
if !config.Security.IsValid() {
|
||||
if !config.Security.ValidateAndSetDefaults() {
|
||||
t.Error("Security config should've been valid")
|
||||
}
|
||||
if config.Security.Basic == nil {
|
||||
@@ -1914,7 +1867,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
IFTTT: &ifttt.AlertProvider{},
|
||||
Ilert: &ilert.AlertProvider{},
|
||||
IncidentIO: &incidentio.AlertProvider{},
|
||||
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
|
||||
Line: &line.AlertProvider{},
|
||||
Matrix: &matrix.AlertProvider{},
|
||||
Mattermost: &mattermost.AlertProvider{},
|
||||
@@ -1959,7 +1911,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
{alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},
|
||||
{alertType: alert.TypeIlert, expected: alertingConfig.Ilert},
|
||||
{alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},
|
||||
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
|
||||
{alertType: alert.TypeLine, expected: alertingConfig.Line},
|
||||
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
|
||||
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
|
||||
@@ -2484,3 +2435,193 @@ suites:
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTunnelingConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid tunneling config",
|
||||
config: &Config{
|
||||
Tunneling: &tunneling.Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "test-endpoint",
|
||||
URL: "http://example.com/health",
|
||||
ClientConfig: &client.Config{
|
||||
Tunnel: "test",
|
||||
},
|
||||
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid tunnel reference in endpoint",
|
||||
config: &Config{
|
||||
Tunneling: &tunneling.Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "test-endpoint",
|
||||
URL: "http://example.com/health",
|
||||
ClientConfig: &client.Config{
|
||||
Tunnel: "nonexistent",
|
||||
},
|
||||
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "endpoint '_test-endpoint': tunnel 'nonexistent' not found in tunneling configuration",
|
||||
},
|
||||
{
|
||||
name: "invalid tunnel reference in suite endpoint",
|
||||
config: &Config{
|
||||
Tunneling: &tunneling.Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
Suites: []*suite.Suite{
|
||||
{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "suite-endpoint",
|
||||
URL: "http://example.com/health",
|
||||
ClientConfig: &client.Config{
|
||||
Tunnel: "invalid",
|
||||
},
|
||||
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "suite '_test-suite' endpoint '_suite-endpoint': tunnel 'invalid' not found in tunneling configuration",
|
||||
},
|
||||
{
|
||||
name: "no tunneling config",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "test-endpoint",
|
||||
URL: "http://example.com/health",
|
||||
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateTunnelingConfig(tt.config)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("ValidateTunnelingConfig() expected error but got none")
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.errMsg {
|
||||
t.Errorf("ValidateTunnelingConfig() error = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ValidateTunnelingConfig() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTunnelForClientConfig(t *testing.T) {
|
||||
config := &Config{
|
||||
Tunneling: &tunneling.Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := config.Tunneling.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to validate tunnel config: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
clientConfig *client.Config
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid tunnel reference",
|
||||
clientConfig: &client.Config{
|
||||
Tunnel: "test",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid tunnel reference",
|
||||
clientConfig: &client.Config{
|
||||
Tunnel: "nonexistent",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "tunnel 'nonexistent' not found in tunneling configuration",
|
||||
},
|
||||
{
|
||||
name: "no tunnel reference",
|
||||
clientConfig: &client.Config{},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := resolveTunnelForClientConfig(config, tt.clientConfig)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("resolveTunnelForClientConfig() expected error but got none")
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.errMsg {
|
||||
t.Errorf("resolveTunnelForClientConfig() error = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("resolveTunnelForClientConfig() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,35 +209,79 @@ func sanitizeAndResolveNumericalWithContext(list []string, result *Result, conte
|
||||
}
|
||||
|
||||
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 := 0; i < 2; i++ {
|
||||
// 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"
|
||||
if strings.HasSuffix(s, "0m") {
|
||||
s = strings.TrimSuffix(s, "0m")
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
||||
func prettify(parameters []string, resolvedParameters []string, operator string) string {
|
||||
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
|
||||
// we'll return the resolvedParameters as-is.
|
||||
if strings.HasSuffix(resolvedParameters[0], InvalidConditionElementSuffix) || strings.HasSuffix(resolvedParameters[1], InvalidConditionElementSuffix) {
|
||||
return resolvedParameters[0] + " " + operator + " " + resolvedParameters[1]
|
||||
}
|
||||
// If using the pattern function, truncate the parameter it's being compared to if said parameter is long enough
|
||||
// Handle pattern function truncation first
|
||||
if strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[1] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[1])
|
||||
}
|
||||
if strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[0] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[0])
|
||||
}
|
||||
// First element is a placeholder
|
||||
if parameters[0] != resolvedParameters[0] && parameters[1] == resolvedParameters[1] {
|
||||
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1]
|
||||
// Determine the state of each parameter
|
||||
leftChanged := parameters[0] != resolvedParameters[0]
|
||||
rightChanged := parameters[1] != resolvedParameters[1]
|
||||
leftInvalid := resolvedParameters[0] == parameters[0]+" "+InvalidConditionElementSuffix
|
||||
rightInvalid := resolvedParameters[1] == parameters[1]+" "+InvalidConditionElementSuffix
|
||||
// Build the output based on what was resolved
|
||||
var left, right string
|
||||
// Format left side
|
||||
if leftChanged && !leftInvalid {
|
||||
left = parameters[0] + " (" + resolvedParameters[0] + ")"
|
||||
} else if leftInvalid {
|
||||
left = resolvedParameters[0] // Already has (INVALID)
|
||||
} else {
|
||||
left = parameters[0] // Unchanged
|
||||
}
|
||||
// Second element is a placeholder
|
||||
if parameters[0] == resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
|
||||
return parameters[0] + " " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
// Format right side
|
||||
if rightChanged && !rightInvalid {
|
||||
right = parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
} else if rightInvalid {
|
||||
right = resolvedParameters[1] // Already has (INVALID)
|
||||
} else {
|
||||
right = parameters[1] // Unchanged
|
||||
}
|
||||
// Both elements are placeholders...?
|
||||
if parameters[0] != resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
|
||||
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
}
|
||||
// Neither elements are placeholders
|
||||
return parameters[0] + " " + operator + " " + parameters[1]
|
||||
return left + " " + operator + " " + right
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
)
|
||||
|
||||
func TestCondition_Validate(t *testing.T) {
|
||||
@@ -474,7 +476,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
|
||||
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000",
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (336h) > 2419200000",
|
||||
},
|
||||
{
|
||||
Name: "certificate-expiration-greater-than-duration",
|
||||
@@ -488,7 +490,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
|
||||
Result: &Result{CertificateExpiration: 24 * time.Hour},
|
||||
ExpectedSuccess: false,
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
|
||||
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (24h) > 48h",
|
||||
},
|
||||
{
|
||||
Name: "no-placeholders",
|
||||
@@ -777,3 +779,77 @@ func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
||||
t.Error("condition was invalid, result should've had an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {
|
||||
// Test case: Suite endpoint with invalid context placeholder
|
||||
// This should display the original placeholder names with resolved values
|
||||
condition := Condition("[STATUS] == [CONTEXT].expected_statusz")
|
||||
result := &Result{HTTPStatus: 200}
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
// Note: expected_statusz is not in the context (typo - should be expected_status)
|
||||
"expected_status": 200,
|
||||
"max_response_time": 5000,
|
||||
})
|
||||
// Simulate suite endpoint evaluation with context
|
||||
success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default)
|
||||
if success {
|
||||
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
|
||||
}
|
||||
if len(result.ConditionResults) == 0 {
|
||||
t.Fatal("No condition results found")
|
||||
}
|
||||
actualDisplay := result.ConditionResults[0].Condition
|
||||
// The expected format should preserve the placeholder names
|
||||
expectedDisplay := "[STATUS] (200) == [CONTEXT].expected_statusz (INVALID)"
|
||||
if actualDisplay != expectedDisplay {
|
||||
t.Errorf("Incorrect condition display for failed context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {
|
||||
// Test case: Suite endpoint with valid context placeholder
|
||||
condition := Condition("[STATUS] == [CONTEXT].expected_status")
|
||||
result := &Result{HTTPStatus: 200}
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
"expected_status": 200,
|
||||
})
|
||||
// Simulate suite endpoint evaluation with context
|
||||
success := condition.evaluate(result, false, ctx)
|
||||
if !success {
|
||||
t.Error("Condition should have succeeded")
|
||||
}
|
||||
if len(result.ConditionResults) == 0 {
|
||||
t.Fatal("No condition results found")
|
||||
}
|
||||
actualDisplay := result.ConditionResults[0].Condition
|
||||
// For successful conditions, just the original condition is shown
|
||||
expectedDisplay := "[STATUS] == [CONTEXT].expected_status"
|
||||
if actualDisplay != expectedDisplay {
|
||||
t.Errorf("Incorrect condition display for successful context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {
|
||||
// Test case: One valid placeholder, one invalid
|
||||
// Note: For numerical comparisons, invalid placeholders that can't be parsed as numbers
|
||||
// default to 0 due to sanitizeAndResolveNumericalWithContext's behavior
|
||||
condition := Condition("[RESPONSE_TIME] < [CONTEXT].invalid_key")
|
||||
result := &Result{Duration: 100 * 1000000} // 100ms in nanoseconds
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
"valid_key": 5000,
|
||||
})
|
||||
// Simulate suite endpoint evaluation with context
|
||||
success := condition.evaluate(result, false, ctx)
|
||||
if success {
|
||||
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
|
||||
}
|
||||
if len(result.ConditionResults) == 0 {
|
||||
t.Fatal("No condition results found")
|
||||
}
|
||||
actualDisplay := result.ConditionResults[0].Condition
|
||||
// For numerical comparisons, invalid context placeholders become 0
|
||||
expectedDisplay := "[RESPONSE_TIME] (100) < [CONTEXT].invalid_key (0)"
|
||||
if actualDisplay != expectedDisplay {
|
||||
t.Errorf("Incorrect condition display\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ const (
|
||||
TypeSTARTTLS Type = "STARTTLS"
|
||||
TypeTLS Type = "TLS"
|
||||
TypeHTTP Type = "HTTP"
|
||||
TypeGRPC Type = "GRPC"
|
||||
TypeWS Type = "WEBSOCKET"
|
||||
TypeSSH Type = "SSH"
|
||||
TypeUNKNOWN Type = "UNKNOWN"
|
||||
@@ -177,6 +178,8 @@ func (e *Endpoint) Type() Type {
|
||||
return TypeTLS
|
||||
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
|
||||
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://"):
|
||||
return TypeWS
|
||||
case strings.HasPrefix(e.URL, "ssh://"):
|
||||
@@ -353,6 +356,9 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
|
||||
}
|
||||
result.port = ""
|
||||
}
|
||||
if processedEndpoint.UIConfig.HideErrors {
|
||||
result.Errors = nil
|
||||
}
|
||||
if processedEndpoint.UIConfig.HideConditions {
|
||||
result.ConditionResults = nil
|
||||
}
|
||||
@@ -498,7 +504,7 @@ func (e *Endpoint) call(result *Result) {
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeSSH {
|
||||
// If there's no username/password 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) {
|
||||
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
@@ -514,12 +520,30 @@ func (e *Endpoint) call(result *Result) {
|
||||
result.AddError(err.Error())
|
||||
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 {
|
||||
result.AddError(err.Error())
|
||||
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)
|
||||
} 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 {
|
||||
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
|
||||
result.Duration = time.Since(startTime)
|
||||
@@ -565,13 +589,21 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
|
||||
return request
|
||||
}
|
||||
|
||||
// needsToReadBody checks if there's any condition that requires the response Body to be read
|
||||
// needsToReadBody checks if there's any condition or store mapping that requires the response Body to be read
|
||||
func (e *Endpoint) needsToReadBody() bool {
|
||||
for _, condition := range e.Conditions {
|
||||
if condition.hasBodyPlaceholder() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Check store values for body placeholders
|
||||
if e.Store != nil {
|
||||
for _, value := range e.Store {
|
||||
if strings.Contains(value, BodyPlaceholder) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -914,6 +914,40 @@ func TestEndpoint_needsToReadBody(t *testing.T) {
|
||||
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
// Test store configuration with body placeholder
|
||||
storeWithBodyPlaceholder := map[string]string{
|
||||
"token": "[BODY].accessToken",
|
||||
}
|
||||
if !(&Endpoint{
|
||||
Conditions: []Condition{statusCondition},
|
||||
Store: storeWithBodyPlaceholder,
|
||||
}).needsToReadBody() {
|
||||
t.Error("expected true when store has body placeholder, got false")
|
||||
}
|
||||
// Test store configuration without body placeholder
|
||||
storeWithoutBodyPlaceholder := map[string]string{
|
||||
"status": "[STATUS]",
|
||||
}
|
||||
if (&Endpoint{
|
||||
Conditions: []Condition{statusCondition},
|
||||
Store: storeWithoutBodyPlaceholder,
|
||||
}).needsToReadBody() {
|
||||
t.Error("expected false when store has no body placeholder, got true")
|
||||
}
|
||||
// Test empty store
|
||||
if (&Endpoint{
|
||||
Conditions: []Condition{statusCondition},
|
||||
Store: map[string]string{},
|
||||
}).needsToReadBody() {
|
||||
t.Error("expected false when store is empty, got true")
|
||||
}
|
||||
// Test nil store
|
||||
if (&Endpoint{
|
||||
Conditions: []Condition{statusCondition},
|
||||
Store: nil,
|
||||
}).needsToReadBody() {
|
||||
t.Error("expected false when store is nil, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {
|
||||
@@ -1414,3 +1448,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 := result.ConditionResults != nil && len(result.ConditionResults) > 0
|
||||
if hasConditions != tt.expectConditions {
|
||||
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ type Config struct {
|
||||
// HidePort whether to hide the port in the Result
|
||||
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 bool `yaml:"dont-resolve-failed-conditions"`
|
||||
|
||||
@@ -58,6 +61,7 @@ func GetDefaultConfig() *Config {
|
||||
HideHostname: false,
|
||||
HideURL: false,
|
||||
HidePort: false,
|
||||
HideErrors: false,
|
||||
DontResolveFailedConditions: false,
|
||||
HideConditions: false,
|
||||
Badge: &Badge{
|
||||
|
||||
+3
-1
@@ -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, "&", "-")
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,21 @@ func TestConvertGroupAndNameToKey(t *testing.T) {
|
||||
Name: "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 {
|
||||
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
|
||||
|
||||
@@ -31,17 +31,17 @@ var (
|
||||
//
|
||||
// Uses UTC by default.
|
||||
type Config struct {
|
||||
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
|
||||
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
|
||||
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
|
||||
Timezone string `yaml:"timezone"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
|
||||
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
|
||||
Start string `yaml:"start,omitempty"` // Time at which the maintenance period starts (e.g. 23:00)
|
||||
Duration time.Duration `yaml:"duration,omitempty"` // Duration of the maintenance period (e.g. 4h)
|
||||
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.
|
||||
// See longDayNames for list of valid values.
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -90,13 +90,13 @@ func (c *Config) ValidateAndSetDefaults() error {
|
||||
return errInvalidMaintenanceDuration
|
||||
}
|
||||
if c.Timezone != "" {
|
||||
c.TimezoneLocation, err = time.LoadLocation(c.Timezone)
|
||||
c.timezoneLocation, err = time.LoadLocation(c.Timezone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", errInvalidTimezone, err)
|
||||
}
|
||||
} else {
|
||||
c.Timezone = "UTC"
|
||||
c.TimezoneLocation = time.UTC
|
||||
c.timezoneLocation = time.UTC
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -107,8 +107,8 @@ func (c *Config) IsUnderMaintenance() bool {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
if c.TimezoneLocation != nil {
|
||||
now = now.In(c.TimezoneLocation)
|
||||
if c.timezoneLocation != nil {
|
||||
now = now.In(c.timezoneLocation)
|
||||
}
|
||||
adjustedDate := now.Day()
|
||||
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
@@ -175,10 +176,12 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
|
||||
return nil, nil
|
||||
}
|
||||
storedValues := make(map[string]interface{})
|
||||
var extractionErrors []string
|
||||
for contextKey, placeholder := range mappings {
|
||||
value, err := extractValueForStorage(placeholder, result)
|
||||
if err != nil {
|
||||
// Continue storing other values even if one fails
|
||||
extractionErrors = append(extractionErrors, fmt.Sprintf("%s: %v", contextKey, err))
|
||||
storedValues[contextKey] = fmt.Sprintf("ERROR: %v", err)
|
||||
continue
|
||||
}
|
||||
@@ -187,6 +190,10 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
|
||||
}
|
||||
storedValues[contextKey] = value
|
||||
}
|
||||
// Return an error if any values failed to extract
|
||||
if len(extractionErrors) > 0 {
|
||||
return storedValues, fmt.Errorf("failed to extract values: %s", strings.Join(extractionErrors, "; "))
|
||||
}
|
||||
return storedValues, nil
|
||||
}
|
||||
|
||||
@@ -197,6 +204,11 @@ func extractValueForStorage(placeholder string, result *endpoint.Result) (interf
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Check if the resolution resulted in an INVALID placeholder
|
||||
// This happens when a path doesn't exist (e.g., [BODY].nonexistent)
|
||||
if strings.HasSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix) {
|
||||
return nil, fmt.Errorf("invalid path: %s", strings.TrimSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix))
|
||||
}
|
||||
// Try to parse as number or boolean to store as proper types
|
||||
// Try int first for whole numbers
|
||||
if num, err := strconv.ParseInt(resolved, 10, 64); err == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package suite
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -240,6 +241,50 @@ func TestStoreResultValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreResultValuesWithInvalidPath(t *testing.T) {
|
||||
ctx := gontext.New(map[string]interface{}{})
|
||||
result := &endpoint.Result{
|
||||
HTTPStatus: 200,
|
||||
Body: []byte(`{"data": {"name": "john"}}`),
|
||||
}
|
||||
// Define store mappings with invalid paths
|
||||
mappings := map[string]string{
|
||||
"valid_status": "[STATUS]",
|
||||
"invalid_token": "[BODY].accessToken", // This path doesn't exist
|
||||
"invalid_nested": "[BODY].user.id.invalid", // This nested path doesn't exist
|
||||
}
|
||||
// Store values - should return error for invalid paths
|
||||
stored, err := StoreResultValues(ctx, mappings, result)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when storing invalid paths, got nil")
|
||||
}
|
||||
// Check that the error message contains information about the invalid paths
|
||||
if !strings.Contains(err.Error(), "invalid_token") {
|
||||
t.Errorf("Error should mention invalid_token, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Errorf("Error should mention 'invalid path', got: %v", err)
|
||||
}
|
||||
// Verify that valid values were still stored
|
||||
if stored["valid_status"] != int64(200) {
|
||||
t.Errorf("Expected valid_status=200, got %v", stored["valid_status"])
|
||||
}
|
||||
// Verify that invalid values show error messages in stored map
|
||||
if !strings.Contains(stored["invalid_token"].(string), "ERROR") {
|
||||
t.Errorf("Expected invalid_token to contain ERROR, got %v", stored["invalid_token"])
|
||||
}
|
||||
// Verify that invalid values are NOT in context
|
||||
_, err = ctx.Get("invalid_token")
|
||||
if err == nil {
|
||||
t.Error("Invalid token should not be stored in context")
|
||||
}
|
||||
// Verify that valid value IS in context
|
||||
val, err := ctx.Get("valid_status")
|
||||
if err != nil || val != int64(200) {
|
||||
t.Errorf("Expected valid_status=200 in context, got %v, err=%v", val, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_ExecuteWithAlwaysRunEndpoints(t *testing.T) {
|
||||
suite := &Suite{
|
||||
Name: "test-suite",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package sshtunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Config represents the configuration for an SSH tunnel
|
||||
type Config struct {
|
||||
Type string `yaml:"type"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port,omitempty"`
|
||||
Username string `yaml:"username"`
|
||||
PrivateKey string `yaml:"private-key,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the SSH tunnel configuration and sets defaults
|
||||
func (c *Config) ValidateAndSetDefaults() error {
|
||||
if c.Type != "SSH" {
|
||||
return fmt.Errorf("unsupported tunnel type: %s", c.Type)
|
||||
}
|
||||
if c.Host == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
}
|
||||
if c.Username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
if c.PrivateKey == "" && c.Password == "" {
|
||||
return fmt.Errorf("either private-key or password is required")
|
||||
}
|
||||
if c.Port == 0 {
|
||||
c.Port = 22
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSHTunnel represents an SSH tunnel connection
|
||||
type SSHTunnel struct {
|
||||
config *Config
|
||||
mu sync.RWMutex
|
||||
client *ssh.Client
|
||||
|
||||
// Cached authentication methods to avoid reparsing private keys
|
||||
authMethods []ssh.AuthMethod
|
||||
}
|
||||
|
||||
// New creates a new SSH tunnel with the given configuration
|
||||
func New(config *Config) *SSHTunnel {
|
||||
tunnel := &SSHTunnel{
|
||||
config: config,
|
||||
}
|
||||
// Parse authentication methods once during initialization to avoid
|
||||
// expensive cryptographic operations on every connection attempt
|
||||
if config.PrivateKey != "" {
|
||||
if signer, err := ssh.ParsePrivateKey([]byte(config.PrivateKey)); err == nil {
|
||||
tunnel.authMethods = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||||
}
|
||||
// Note: We don't return error here to maintain backward compatibility.
|
||||
// Invalid keys will be caught during first connection attempt.
|
||||
} else if config.Password != "" {
|
||||
tunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}
|
||||
}
|
||||
return tunnel
|
||||
}
|
||||
|
||||
// Connect establishes the SSH connection
|
||||
func (t *SSHTunnel) Connect() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.connectUnsafe()
|
||||
}
|
||||
|
||||
// connectUnsafe establishes the SSH connection without acquiring locks
|
||||
// Must be called with t.mu.Lock() already held
|
||||
func (t *SSHTunnel) connectUnsafe() error {
|
||||
// Use cached authentication methods to avoid expensive crypto operations
|
||||
if len(t.authMethods) == 0 {
|
||||
return fmt.Errorf("no authentication method available")
|
||||
}
|
||||
config := &ssh.ClientConfig{
|
||||
User: t.config.Username,
|
||||
Timeout: 30 * time.Second,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Skip host key verification
|
||||
Auth: t.authMethods, // Use pre-parsed authentication
|
||||
}
|
||||
// Connect to SSH server
|
||||
addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH connection failed: %w", err)
|
||||
}
|
||||
t.client = client
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the SSH connection
|
||||
func (t *SSHTunnel) Close() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.client != nil {
|
||||
err := t.client.Close()
|
||||
t.client = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dial creates a connection through the SSH tunnel
|
||||
func (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {
|
||||
t.mu.RLock()
|
||||
client := t.client
|
||||
t.mu.RUnlock()
|
||||
// Ensure we have an SSH connection
|
||||
if client == nil {
|
||||
// Use write lock to prevent race condition during connection
|
||||
t.mu.Lock()
|
||||
// Double-check client after acquiring lock
|
||||
if t.client == nil {
|
||||
if err := t.connectUnsafe(); err != nil {
|
||||
t.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
client = t.client
|
||||
t.mu.Unlock()
|
||||
}
|
||||
// Attempt dial with exponential backoff retry
|
||||
const maxRetries = 3
|
||||
const baseDelay = 500 * time.Millisecond
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Exponential backoff: 500ms, 1s, 2s
|
||||
delay := baseDelay << (attempt - 1)
|
||||
time.Sleep(delay)
|
||||
// Close stale connection and reconnect
|
||||
t.mu.Lock()
|
||||
if t.client != nil {
|
||||
_ = t.client.Close()
|
||||
t.client = nil
|
||||
}
|
||||
if err := t.connectUnsafe(); err != nil {
|
||||
t.mu.Unlock()
|
||||
lastErr = fmt.Errorf("reconnect attempt %d failed: %w", attempt, err)
|
||||
continue
|
||||
}
|
||||
client = t.client
|
||||
t.mu.Unlock()
|
||||
}
|
||||
conn, err := client.Dial(network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("SSH tunnel dial failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package sshtunnel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid SSH config with private key",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid SSH config with password",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid SSH config with custom port",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Port: 2222,
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "sets default port 22",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
config: &Config{
|
||||
Type: "INVALID",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "unsupported tunnel type: INVALID",
|
||||
},
|
||||
{
|
||||
name: "missing host",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "host is required",
|
||||
},
|
||||
{
|
||||
name: "missing username",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "username is required",
|
||||
},
|
||||
{
|
||||
name: "missing authentication",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "either private-key or password is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
originalPort := tt.config.Port
|
||||
err := tt.config.ValidateAndSetDefaults()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ValidateAndSetDefaults() expected error but got none")
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.errMsg {
|
||||
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
// Check that default port is set
|
||||
if originalPort == 0 && tt.config.Port != 22 {
|
||||
t.Errorf("ValidateAndSetDefaults() expected default port 22, got %d", tt.config.Port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
config := &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
}
|
||||
tunnel := New(config)
|
||||
if tunnel == nil {
|
||||
t.Error("New() returned nil")
|
||||
return
|
||||
}
|
||||
if tunnel.config != config {
|
||||
t.Error("New() did not set config correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHTunnel_Close(t *testing.T) {
|
||||
config := &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
}
|
||||
tunnel := New(config)
|
||||
// Test closing when no client is set
|
||||
err := tunnel.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() with no client returned error: %v", err)
|
||||
}
|
||||
// Test closing multiple times
|
||||
err = tunnel.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() called twice returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package tunneling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
|
||||
)
|
||||
|
||||
// Config represents the tunneling configuration
|
||||
type Config struct {
|
||||
// Tunnels is a map of SSH tunnel configurations in which the key is the name of the tunnel
|
||||
Tunnels map[string]*sshtunnel.Config `yaml:",inline"`
|
||||
|
||||
mu sync.RWMutex `yaml:"-"`
|
||||
connections map[string]*sshtunnel.SSHTunnel `yaml:"-"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the tunneling configuration and sets defaults
|
||||
func (tc *Config) ValidateAndSetDefaults() error {
|
||||
if tc.connections == nil {
|
||||
tc.connections = make(map[string]*sshtunnel.SSHTunnel)
|
||||
}
|
||||
for name, config := range tc.Tunnels {
|
||||
if err := config.ValidateAndSetDefaults(); err != nil {
|
||||
return fmt.Errorf("tunnel '%s': %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTunnel returns the SSH tunnel for the given name, creating it if necessary
|
||||
func (tc *Config) GetTunnel(name string) (*sshtunnel.SSHTunnel, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("tunnel name cannot be empty")
|
||||
}
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
// Check if tunnel already exists
|
||||
if tunnel, exists := tc.connections[name]; exists {
|
||||
return tunnel, nil
|
||||
}
|
||||
// Get config for this tunnel
|
||||
config, exists := tc.Tunnels[name]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("tunnel '%s' not found in configuration", name)
|
||||
}
|
||||
// Create and store new tunnel
|
||||
tunnel := sshtunnel.New(config)
|
||||
tc.connections[name] = tunnel
|
||||
return tunnel, nil
|
||||
}
|
||||
|
||||
// Close closes all SSH tunnel connections
|
||||
func (tc *Config) Close() error {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
var errors []string
|
||||
for name, tunnel := range tc.connections {
|
||||
if err := tunnel.Close(); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("tunnel '%s': %v", name, err))
|
||||
}
|
||||
delete(tc.connections, name)
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("failed to close tunnels: %s", strings.Join(errors, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package tunneling
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
|
||||
)
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid config with SSH tunnel",
|
||||
config: &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple valid tunnels",
|
||||
config: &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"tunnel1": {
|
||||
Type: "SSH",
|
||||
Host: "host1.com",
|
||||
Username: "user1",
|
||||
PrivateKey: "key1",
|
||||
},
|
||||
"tunnel2": {
|
||||
Type: "SSH",
|
||||
Host: "host2.com",
|
||||
Username: "user2",
|
||||
Password: "pass2",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid tunnel config",
|
||||
config: &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"invalid": {
|
||||
Type: "INVALID",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "tunnel 'invalid': unsupported tunnel type: INVALID",
|
||||
},
|
||||
{
|
||||
name: "missing host in tunnel",
|
||||
config: &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"nohost": {
|
||||
Type: "SSH",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "tunnel 'nohost': host is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.ValidateAndSetDefaults()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ValidateAndSetDefaults() expected error but got none")
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.errMsg {
|
||||
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
// Check that connections map is initialized
|
||||
if tt.config != nil && tt.config.connections == nil {
|
||||
t.Error("ValidateAndSetDefaults() did not initialize connections map")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_GetTunnel(t *testing.T) {
|
||||
config := &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := config.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
|
||||
}
|
||||
// Test getting existing tunnel
|
||||
tunnel1, err := config.GetTunnel("test")
|
||||
if err != nil {
|
||||
t.Errorf("GetTunnel() error = %v", err)
|
||||
return
|
||||
}
|
||||
if tunnel1 == nil {
|
||||
t.Error("GetTunnel() returned nil tunnel")
|
||||
return
|
||||
}
|
||||
// Test getting same tunnel again (should return same instance)
|
||||
tunnel2, err := config.GetTunnel("test")
|
||||
if err != nil {
|
||||
t.Errorf("GetTunnel() second call error = %v", err)
|
||||
return
|
||||
}
|
||||
if tunnel1 != tunnel2 {
|
||||
t.Error("GetTunnel() should return same instance for same tunnel name")
|
||||
}
|
||||
// Test getting non-existent tunnel
|
||||
_, err = config.GetTunnel("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("GetTunnel() expected error for non-existent tunnel")
|
||||
return
|
||||
}
|
||||
expectedErr := "tunnel 'nonexistent' not found in configuration"
|
||||
if err.Error() != expectedErr {
|
||||
t.Errorf("GetTunnel() error = %v, want %v", err.Error(), expectedErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Close(t *testing.T) {
|
||||
// Test closing config with tunnels
|
||||
config := &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test1": {
|
||||
Type: "SSH",
|
||||
Host: "example1.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
"test2": {
|
||||
Type: "SSH",
|
||||
Host: "example2.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := config.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
|
||||
}
|
||||
// Create some tunnels
|
||||
_, err = config.GetTunnel("test1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTunnel() failed: %v", err)
|
||||
}
|
||||
_, err = config.GetTunnel("test2")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTunnel() failed: %v", err)
|
||||
}
|
||||
// Test closing
|
||||
err = config.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() returned error: %v", err)
|
||||
}
|
||||
// Verify connections map is empty
|
||||
if len(config.connections) != 0 {
|
||||
t.Errorf("Close() did not clear connections map, got %d connections", len(config.connections))
|
||||
}
|
||||
}
|
||||
+30
-19
@@ -10,14 +10,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
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"
|
||||
defaultHeader = "Gatus"
|
||||
defaultLogo = ""
|
||||
defaultLink = ""
|
||||
defaultCustomCSS = ""
|
||||
defaultSortBy = "name"
|
||||
defaultFilterBy = "none"
|
||||
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"
|
||||
defaultHeader = "Gatus"
|
||||
defaultDashboardHeading = "Health Dashboard"
|
||||
defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
|
||||
defaultLogo = ""
|
||||
defaultLink = ""
|
||||
defaultCustomCSS = ""
|
||||
defaultSortBy = "name"
|
||||
defaultFilterBy = "none"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,17 +32,18 @@ var (
|
||||
|
||||
// Config is the configuration for the UI of Gatus
|
||||
type Config struct {
|
||||
Title string `yaml:"title,omitempty"` // Title of the page
|
||||
Description string `yaml:"description,omitempty"` // Meta description of the page
|
||||
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
|
||||
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||
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
|
||||
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')
|
||||
|
||||
Title string `yaml:"title,omitempty"` // Title 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
|
||||
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
|
||||
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
|
||||
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
|
||||
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
|
||||
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')
|
||||
//////////////////////////////////////////////
|
||||
// Non-configurable - used for UI rendering //
|
||||
//////////////////////////////////////////////
|
||||
@@ -73,6 +76,8 @@ func GetDefaultConfig() *Config {
|
||||
return &Config{
|
||||
Title: defaultTitle,
|
||||
Description: defaultDescription,
|
||||
DashboardHeading: defaultDashboardHeading,
|
||||
DashboardSubheading: defaultDashboardSubheading,
|
||||
Header: defaultHeader,
|
||||
Logo: defaultLogo,
|
||||
Link: defaultLink,
|
||||
@@ -92,6 +97,12 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
if len(cfg.Description) == 0 {
|
||||
cfg.Description = defaultDescription
|
||||
}
|
||||
if len(cfg.DashboardHeading) == 0 {
|
||||
cfg.DashboardHeading = defaultDashboardHeading
|
||||
}
|
||||
if len(cfg.DashboardSubheading) == 0 {
|
||||
cfg.DashboardSubheading = defaultDashboardSubheading
|
||||
}
|
||||
if len(cfg.Header) == 0 {
|
||||
cfg.Header = defaultHeader
|
||||
}
|
||||
|
||||
+110
-25
@@ -7,31 +7,110 @@ import (
|
||||
)
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Title: "",
|
||||
Description: "",
|
||||
Header: "",
|
||||
Logo: "",
|
||||
Link: "",
|
||||
}
|
||||
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if cfg.Title != defaultTitle {
|
||||
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
|
||||
}
|
||||
if cfg.Description != defaultDescription {
|
||||
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
|
||||
}
|
||||
if cfg.Header != defaultHeader {
|
||||
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
|
||||
}
|
||||
if cfg.DefaultSortBy != defaultSortBy {
|
||||
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
|
||||
}
|
||||
if cfg.DefaultFilterBy != defaultFilterBy {
|
||||
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
|
||||
}
|
||||
t.Run("empty-config", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Title: "",
|
||||
Description: "",
|
||||
DashboardHeading: "",
|
||||
DashboardSubheading: "",
|
||||
Header: "",
|
||||
Logo: "",
|
||||
Link: "",
|
||||
}
|
||||
if err := cfg.ValidateAndSetDefaults(); err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if cfg.Title != defaultTitle {
|
||||
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
|
||||
}
|
||||
if cfg.Description != defaultDescription {
|
||||
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 {
|
||||
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
|
||||
}
|
||||
if cfg.DefaultSortBy != defaultSortBy {
|
||||
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
|
||||
}
|
||||
if cfg.DefaultFilterBy != defaultFilterBy {
|
||||
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
|
||||
}
|
||||
})
|
||||
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",
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestButton_Validate(t *testing.T) {
|
||||
@@ -78,6 +157,12 @@ func TestGetDefaultConfig(t *testing.T) {
|
||||
if defaultConfig.Title != defaultTitle {
|
||||
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 {
|
||||
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
|
||||
}
|
||||
|
||||
@@ -5,58 +5,71 @@ go 1.24.4
|
||||
toolchain go1.24.7
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.21.0
|
||||
code.gitea.io/sdk/gitea v0.22.0
|
||||
github.com/TwiN/deepmerge v0.2.2
|
||||
github.com/TwiN/g8/v2 v2.0.0
|
||||
github.com/TwiN/gocache/v2 v2.4.0
|
||||
github.com/TwiN/health v1.6.0
|
||||
github.com/TwiN/logr v0.3.1
|
||||
github.com/TwiN/whois v1.1.11
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/gofiber/fiber/v2 v2.52.8
|
||||
github.com/TwiN/whois v1.2.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.2
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.12
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5
|
||||
github.com/coreos/go-oidc/v3 v3.16.0
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/google/go-github/v48 v48.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/miekg/dns v1.1.68
|
||||
github.com/prometheus-community/pro-bing v0.7.0
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/registrobr/rdap v1.1.8
|
||||
github.com/valyala/fasthttp v1.64.0
|
||||
github.com/valyala/fasthttp v1.67.0
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/net v0.42.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sync v0.16.0
|
||||
google.golang.org/api v0.242.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/oauth2 v0.32.0
|
||||
golang.org/x/sync v0.17.0
|
||||
google.golang.org/api v0.252.0
|
||||
google.golang.org/grpc v1.75.1
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.38.2
|
||||
modernc.org/sqlite v1.39.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.16.2 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.2 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect
|
||||
github.com/aws/smithy-go v1.23.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
@@ -65,27 +78,27 @@ require (
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // 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
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/grpc v1.73.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // 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.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
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/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
|
||||
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
|
||||
github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA=
|
||||
github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0=
|
||||
code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
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/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
|
||||
@@ -18,21 +18,46 @@ 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/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
|
||||
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.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
|
||||
github.com/TwiN/whois v1.2.0 h1:/Z22SrS3Z0FQgMl1+4bKSu9UmEQTfGx9i9J4Hn18eQk=
|
||||
github.com/TwiN/whois v1.2.0/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
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/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I=
|
||||
github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5 h1:NwOeuOFrWoh4xWKINrmaAK4Vh75jmmY0RAuNjQ6W5Es=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5/go.mod h1:m3BsMJZD0eqjGIniBzwrNUqG9ZUPquC4hY9FyE2qNFo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8=
|
||||
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
||||
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
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/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
@@ -43,15 +68,15 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
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/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.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
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.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
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/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
|
||||
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/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/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -72,16 +97,12 @@ 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/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo=
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -109,12 +130,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
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.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
|
||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
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/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=
|
||||
@@ -126,13 +147,12 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og=
|
||||
github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA=
|
||||
github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac=
|
||||
github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM=
|
||||
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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
@@ -144,18 +164,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
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-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
@@ -163,8 +185,8 @@ 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.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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
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=
|
||||
@@ -174,8 +196,8 @@ 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.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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -185,18 +207,18 @@ 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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
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.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
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.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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -210,8 +232,8 @@ 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.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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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-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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
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.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -231,28 +253,30 @@ 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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
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.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.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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
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=
|
||||
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
|
||||
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI=
|
||||
google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/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/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -260,23 +284,20 @@ 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/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -285,8 +306,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
||||
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -59,6 +59,7 @@ func stop(cfg *config.Config) {
|
||||
watchdog.Shutdown(cfg)
|
||||
controller.Shutdown()
|
||||
metrics.UnregisterPrometheusMetrics()
|
||||
closeTunnels(cfg)
|
||||
}
|
||||
|
||||
func save() {
|
||||
@@ -182,11 +183,46 @@ 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 {
|
||||
logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded)
|
||||
}
|
||||
}
|
||||
|
||||
func closeTunnels(cfg *config.Config) {
|
||||
if cfg.Tunneling != nil {
|
||||
if err := cfg.Tunneling.Close(); err != nil {
|
||||
logr.Errorf("[main.closeTunnels] Error closing SSH tunnels: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listenToConfigurationFileChanges(cfg *config.Config) {
|
||||
for {
|
||||
time.Sleep(30 * time.Second)
|
||||
|
||||
@@ -17,6 +17,7 @@ var (
|
||||
resultConnectedTotal *prometheus.CounterVec
|
||||
resultCodeTotal *prometheus.CounterVec
|
||||
resultCertificateExpirationSeconds *prometheus.GaugeVec
|
||||
resultDomainExpirationSeconds *prometheus.GaugeVec
|
||||
resultEndpointSuccess *prometheus.GaugeVec
|
||||
|
||||
// Suite metrics
|
||||
@@ -51,6 +52,9 @@ func UnregisterPrometheusMetrics() {
|
||||
if resultCertificateExpirationSeconds != nil {
|
||||
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
|
||||
}
|
||||
if resultDomainExpirationSeconds != nil {
|
||||
currentRegisterer.Unregister(resultDomainExpirationSeconds)
|
||||
}
|
||||
if resultEndpointSuccess != nil {
|
||||
currentRegisterer.Unregister(resultEndpointSuccess)
|
||||
}
|
||||
@@ -119,6 +123,13 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
|
||||
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
||||
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{
|
||||
Namespace: namespace,
|
||||
Name: "results_endpoint_success",
|
||||
@@ -178,6 +189,9 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
|
||||
if result.CertificateExpiration != 0 {
|
||||
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 {
|
||||
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
|
||||
} else {
|
||||
|
||||
@@ -48,6 +48,9 @@ func TestInitializePrometheusMetrics(t *testing.T) {
|
||||
if resultCertificateExpirationSeconds == nil {
|
||||
t.Error("resultCertificateExpirationSeconds metric not initialized")
|
||||
}
|
||||
if resultDomainExpirationSeconds == nil {
|
||||
t.Error("resultDomainExpirationSeconds metric not initialized")
|
||||
}
|
||||
if resultEndpointSuccess == nil {
|
||||
t.Error("resultEndpointSuccess metric not initialized")
|
||||
}
|
||||
@@ -120,9 +123,11 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[STATUS] == 200", Success: true},
|
||||
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
|
||||
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
|
||||
},
|
||||
Success: true,
|
||||
CertificateExpiration: 49 * time.Hour,
|
||||
DomainExpiration: 25 * time.Hour,
|
||||
}, []string{})
|
||||
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
@@ -154,9 +162,11 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[STATUS] == 200", Success: true},
|
||||
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
|
||||
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
|
||||
},
|
||||
Success: false,
|
||||
CertificateExpiration: 47 * time.Hour,
|
||||
DomainExpiration: 24 * time.Hour,
|
||||
}, []string{})
|
||||
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
|
||||
+3
-3
@@ -26,9 +26,9 @@ type Config struct {
|
||||
gate *g8.Gate
|
||||
}
|
||||
|
||||
// IsValid returns whether the security configuration is valid or not
|
||||
func (c *Config) IsValid() bool {
|
||||
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.isValid())
|
||||
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
|
||||
func (c *Config) ValidateAndSetDefaults() bool {
|
||||
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())
|
||||
}
|
||||
|
||||
// RegisterHandlers registers all handlers required based on the security configuration
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestConfig_IsValid(t *testing.T) {
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
c := &Config{
|
||||
Basic: nil,
|
||||
OIDC: nil,
|
||||
}
|
||||
if c.IsValid() {
|
||||
if c.ValidateAndSetDefaults() {
|
||||
t.Error("expected empty config to be valid")
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) {
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
SessionTTL: DefaultOIDCSessionTTL,
|
||||
oauth2Config: oauth2.Config{},
|
||||
verifier: nil,
|
||||
}}
|
||||
|
||||
+18
-10
@@ -13,21 +13,29 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultOIDCSessionTTL = 8 * time.Hour
|
||||
)
|
||||
|
||||
// OIDCConfig is the configuration for OIDC authentication
|
||||
type OIDCConfig struct {
|
||||
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
|
||||
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
|
||||
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
|
||||
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
|
||||
SessionTTL time.Duration `yaml:"session-ttl"` // e.g. 8h. Defaults to 8 hours
|
||||
|
||||
oauth2Config oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// isValid returns whether the basic security configuration is valid or not
|
||||
func (c *OIDCConfig) isValid() bool {
|
||||
// ValidateAndSetDefaults returns whether the OIDC configuration is valid and sets default values.
|
||||
func (c *OIDCConfig) ValidateAndSetDefaults() bool {
|
||||
if c.SessionTTL <= 0 {
|
||||
c.SessionTTL = DefaultOIDCSessionTTL
|
||||
}
|
||||
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
||||
}
|
||||
|
||||
@@ -131,12 +139,12 @@ func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { /
|
||||
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
|
||||
// At this point, the user has been confirmed. All that's left to do is create a session.
|
||||
sessionID := uuid.NewString()
|
||||
sessions.SetWithTTL(sessionID, idToken.Subject, time.Hour)
|
||||
sessions.SetWithTTL(sessionID, idToken.Subject, c.SessionTTL)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieNameSession,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
MaxAge: int(c.SessionTTL.Seconds()),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
+22
-2
@@ -4,11 +4,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
|
||||
func TestOIDCConfig_isValid(t *testing.T) {
|
||||
func TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
c := &OIDCConfig{
|
||||
IssuerURL: "https://sso.gatus.io/",
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
@@ -16,10 +17,14 @@ func TestOIDCConfig_isValid(t *testing.T) {
|
||||
ClientSecret: "client-secret",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
SessionTTL: 0, // Not set! ValidateAndSetDefaults should set it to DefaultOIDCSessionTTL
|
||||
}
|
||||
if !c.isValid() {
|
||||
if !c.ValidateAndSetDefaults() {
|
||||
t.Error("OIDCConfig should be valid")
|
||||
}
|
||||
if c.SessionTTL != DefaultOIDCSessionTTL {
|
||||
t.Error("expected SessionTTL to be set to DefaultOIDCSessionTTL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConfig_callbackHandler(t *testing.T) {
|
||||
@@ -68,3 +73,18 @@ func TestOIDCConfig_setSessionCookie(t *testing.T) {
|
||||
t.Error("expected cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConfig_setSessionCookieWithCustomTTL(t *testing.T) {
|
||||
customTTL := 30 * time.Minute
|
||||
c := &OIDCConfig{SessionTTL: customTTL}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
|
||||
cookies := responseRecorder.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Error("expected cookie to be set")
|
||||
}
|
||||
sessionCookie := cookies[0]
|
||||
if sessionCookie.MaxAge != int(customTTL.Seconds()) {
|
||||
t.Errorf("expected cookie MaxAge to be %d, but was %d", int(customTTL.Seconds()), sessionCookie.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@ func (s *Store) InsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Res
|
||||
// Get the success value of the previous result
|
||||
var lastResultSuccess bool
|
||||
if lastResultSuccess, err = s.getLastEndpointResultSuccessValue(tx, endpointID); err != nil {
|
||||
// Silently fail
|
||||
logr.Errorf("[sql.InsertEndpointResult] Failed to retrieve outcome of previous result for endpoint with key=%s: %s", ep.Key(), err.Error())
|
||||
} else {
|
||||
// If we managed to retrieve the outcome of the previous result, we'll compare it with the new result.
|
||||
@@ -758,13 +759,19 @@ func (s *Store) getEndpointIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64
|
||||
}
|
||||
|
||||
func (s *Store) getEndpointEventsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (events []*endpoint.Event, err error) {
|
||||
// We need to get the most recent events, but return them in chronological order (oldest to newest)
|
||||
// First, get the most recent events using a subquery, then order them chronologically
|
||||
rows, err := tx.Query(
|
||||
`
|
||||
SELECT event_type, event_timestamp
|
||||
FROM endpoint_events
|
||||
WHERE endpoint_id = $1
|
||||
FROM (
|
||||
SELECT event_type, event_timestamp, endpoint_event_id
|
||||
FROM endpoint_events
|
||||
WHERE endpoint_id = $1
|
||||
ORDER BY endpoint_event_id DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
) AS recent_events
|
||||
ORDER BY endpoint_event_id ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`,
|
||||
endpointID,
|
||||
pageSize,
|
||||
|
||||
@@ -886,3 +886,59 @@ func TestStore_HasEndpointStatusNewerThan(t *testing.T) {
|
||||
t.Error("expected not to have a newer status in the future")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventOrderingFix specifically tests the SQL ordering fix for issue #1040
|
||||
// This test verifies that getEndpointEventsByEndpointID returns the most recent events
|
||||
// in chronological order (oldest to newest)
|
||||
func TestEventOrderingFix(t *testing.T) {
|
||||
store, _ := NewStore("sqlite", t.TempDir()+"/test.db", false, 100, 100)
|
||||
defer store.Close()
|
||||
ep := &endpoint.Endpoint{
|
||||
Name: "ordering-test",
|
||||
Group: "test",
|
||||
URL: "https://example.com",
|
||||
}
|
||||
// Create many events over time
|
||||
baseTime := time.Now().Add(-100 * time.Hour) // Start 100 hours ago
|
||||
for i := 0; i < 50; i++ {
|
||||
result := &endpoint.Result{
|
||||
Success: i%2 == 0, // Alternate between true/false to create events
|
||||
Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
|
||||
}
|
||||
err := store.InsertEndpointResult(ep, result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert result %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Now retrieve events with pagination to test the ordering
|
||||
tx, _ := store.db.Begin()
|
||||
endpointID, _, _, _ := store.getEndpointIDGroupAndNameByKey(tx, ep.Key())
|
||||
// Get the first page (should get the MOST RECENT events, but in chronological order)
|
||||
events, err := store.getEndpointEventsByEndpointID(tx, endpointID, 1, 10)
|
||||
tx.Commit()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get events: %v", err)
|
||||
}
|
||||
if len(events) != 10 {
|
||||
t.Errorf("Expected 10 events, got %d", len(events))
|
||||
}
|
||||
// Verify the events are in chronological order (oldest to newest)
|
||||
for i := 1; i < len(events); i++ {
|
||||
if events[i].Timestamp.Before(events[i-1].Timestamp) {
|
||||
t.Errorf("Events not in chronological order: event %d timestamp %v is before event %d timestamp %v",
|
||||
i, events[i].Timestamp, i-1, events[i-1].Timestamp)
|
||||
}
|
||||
}
|
||||
// Verify these are the most recent events
|
||||
// The last event in the returned list should be close to "now" (within the last few events we created)
|
||||
lastEventTime := events[len(events)-1].Timestamp
|
||||
expectedRecentTime := baseTime.Add(49 * time.Hour) // The most recent event we created
|
||||
timeDiff := expectedRecentTime.Sub(lastEventTime)
|
||||
if timeDiff > 10*time.Hour { // Allow some margin for events
|
||||
t.Errorf("Events are not the most recent ones. Last event time: %v, expected around: %v (diff: %v)",
|
||||
lastEventTime, expectedRecentTime, timeDiff)
|
||||
}
|
||||
t.Logf("Successfully retrieved %d most recent events in chronological order", len(events))
|
||||
t.Logf("First event: %s at %v", events[0].Type, events[0].Timestamp)
|
||||
t.Logf("Last event: %s at %v", events[len(events)-1].Type, events[len(events)-1].Timestamp)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||
"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/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
@@ -325,19 +324,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",
|
||||
AlertType: alert.TypeLine,
|
||||
@@ -648,7 +634,7 @@ func TestHandleAlertingWithMinimumReminderInterval(t *testing.T) {
|
||||
SuccessThreshold: 3,
|
||||
SendOnResolved: &enabled,
|
||||
Triggered: false,
|
||||
MinimumReminderInterval: 1 * time.Second,
|
||||
MinimumReminderInterval: 5 * time.Minute,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ func executeEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []st
|
||||
}
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
logr.Debug("[watchdog.executeEndpoint] Not handling alerting because currently in the maintenance window")
|
||||
|
||||
+2
-2
@@ -50,6 +50,8 @@ func executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) {
|
||||
if cfg.Metrics {
|
||||
metrics.PublishMetricsForSuite(s, result, extraLabels)
|
||||
}
|
||||
// Store result
|
||||
UpdateSuiteStatus(s, result)
|
||||
// Handle alerting for suite endpoints
|
||||
for i, ep := range s.Endpoints {
|
||||
if i < len(result.EndpointResults) {
|
||||
@@ -72,8 +74,6 @@ func executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) {
|
||||
}
|
||||
}
|
||||
logr.Infof("[watchdog.executeSuite] Completed suite=%s; success=%v; errors=%d; duration=%v; endpoints_executed=%d/%d", s.Name, result.Success, len(result.Errors), result.Duration, len(result.EndpointResults), len(s.Endpoints))
|
||||
// Store result in database
|
||||
UpdateSuiteStatus(s, result)
|
||||
}
|
||||
|
||||
// UpdateSuiteStatus persists the suite result in the database
|
||||
|
||||
Generated
+146
-464
@@ -8,16 +8,19 @@
|
||||
"name": "gatus",
|
||||
"version": "4.0.0",
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.45.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.0",
|
||||
"lucide-vue-next": "^0.539.0",
|
||||
"radix-vue": "^1.9.17",
|
||||
"marked": "^16.4.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1861,42 +1864,6 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
|
||||
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@floating-ui/vue": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.8.tgz",
|
||||
"integrity": "sha512-SNJAa1jbT8Gh1LvWw2uIIViLL0saV2bCY59ISCvJzhbut5DSb2H3LKUK49Xkd7SixTNHKX4LFu59nbwIXt9jjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10",
|
||||
"vue-demi": ">=0.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||
@@ -1912,30 +1879,6 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@headlessui/vue": {
|
||||
"version": "1.7.23",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
|
||||
"integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/vue-virtual": "^3.0.0-beta.60"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroicons/vue": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -1974,24 +1917,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
|
||||
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/number": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.4.tgz",
|
||||
"integrity": "sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
||||
@@ -2063,6 +1988,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
||||
@@ -2244,41 +2175,6 @@
|
||||
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-virtual": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz",
|
||||
"integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^2.7.0 || ^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
@@ -2472,11 +2368,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.5.3",
|
||||
@@ -3221,44 +3118,6 @@
|
||||
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz",
|
||||
"integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "13.6.0",
|
||||
"@vueuse/shared": "13.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz",
|
||||
"integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz",
|
||||
"integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
|
||||
@@ -3670,18 +3529,6 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
|
||||
@@ -4170,6 +4017,37 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=2.8.0",
|
||||
"date-fns": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-annotation": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
@@ -5002,6 +4880,16 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
@@ -5146,12 +5034,6 @@
|
||||
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -5298,6 +5180,15 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
|
||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
@@ -6237,7 +6128,8 @@
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.2.11",
|
||||
@@ -7798,6 +7690,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
||||
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
@@ -9535,88 +9439,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue": {
|
||||
"version": "1.9.17",
|
||||
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
|
||||
"integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.7",
|
||||
"@floating-ui/vue": "^1.1.0",
|
||||
"@internationalized/date": "^3.5.4",
|
||||
"@internationalized/number": "^3.5.3",
|
||||
"@tanstack/vue-virtual": "^3.8.1",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/shared": "^10.11.0",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"defu": "^6.1.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"nanoid": "^5.0.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/@vueuse/core": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
|
||||
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vueuse/metadata": "10.11.1",
|
||||
"@vueuse/shared": "10.11.1",
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/@vueuse/metadata": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
|
||||
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/@vueuse/shared": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
|
||||
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue-demi": ">=0.14.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/radix-vue/node_modules/nanoid": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
|
||||
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -10821,6 +10643,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
@@ -11026,30 +10849,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"node_modules/vue-chartjs": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
|
||||
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
"chart.js": "^4.1.1",
|
||||
"vue": "^3.0.0-0 || ^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-eslint-parser": {
|
||||
@@ -13323,38 +13130,6 @@
|
||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"requires": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"@floating-ui/dom": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
|
||||
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
|
||||
"requires": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
|
||||
},
|
||||
"@floating-ui/vue": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.8.tgz",
|
||||
"integrity": "sha512-SNJAa1jbT8Gh1LvWw2uIIViLL0saV2bCY59ISCvJzhbut5DSb2H3LKUK49Xkd7SixTNHKX4LFu59nbwIXt9jjQ==",
|
||||
"requires": {
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10",
|
||||
"vue-demi": ">=0.13.0"
|
||||
}
|
||||
},
|
||||
"@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||
@@ -13370,20 +13145,6 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"@headlessui/vue": {
|
||||
"version": "1.7.23",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
|
||||
"integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
|
||||
"requires": {
|
||||
"@tanstack/vue-virtual": "^3.0.0-beta.60"
|
||||
}
|
||||
},
|
||||
"@heroicons/vue": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
|
||||
"requires": {}
|
||||
},
|
||||
"@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -13407,22 +13168,6 @@
|
||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||
"dev": true
|
||||
},
|
||||
"@internationalized/date": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
|
||||
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
|
||||
"requires": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@internationalized/number": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.4.tgz",
|
||||
"integrity": "sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg==",
|
||||
"requires": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@jridgewell/gen-mapping": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
||||
@@ -13483,6 +13228,11 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||
},
|
||||
"@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
|
||||
@@ -13629,27 +13379,6 @@
|
||||
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
||||
"dev": true
|
||||
},
|
||||
"@swc/helpers": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"requires": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"@tanstack/virtual-core": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="
|
||||
},
|
||||
"@tanstack/vue-virtual": {
|
||||
"version": "3.13.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz",
|
||||
"integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==",
|
||||
"requires": {
|
||||
"@tanstack/virtual-core": "3.13.12"
|
||||
}
|
||||
},
|
||||
"@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
@@ -13840,10 +13569,11 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
|
||||
"@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"optional": true
|
||||
},
|
||||
"@types/ws": {
|
||||
"version": "8.5.3",
|
||||
@@ -14421,27 +14151,6 @@
|
||||
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
|
||||
"dev": true
|
||||
},
|
||||
"@vueuse/core": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz",
|
||||
"integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==",
|
||||
"requires": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "13.6.0",
|
||||
"@vueuse/shared": "13.6.0"
|
||||
}
|
||||
},
|
||||
"@vueuse/metadata": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz",
|
||||
"integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ=="
|
||||
},
|
||||
"@vueuse/shared": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz",
|
||||
"integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@webassemblyjs/ast": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
|
||||
@@ -14776,14 +14485,6 @@
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true
|
||||
},
|
||||
"aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"requires": {
|
||||
"tslib": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"array-flatten": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
|
||||
@@ -15123,6 +14824,26 @@
|
||||
"supports-color": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"requires": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"requires": {}
|
||||
},
|
||||
"chartjs-plugin-annotation": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
@@ -15721,6 +15442,11 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
@@ -15822,11 +15548,6 @@
|
||||
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
|
||||
"dev": true
|
||||
},
|
||||
"defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
|
||||
},
|
||||
"depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -15936,6 +15657,14 @@
|
||||
"domelementtype": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"dompurify": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
|
||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||
"requires": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
@@ -16615,7 +16344,8 @@
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"fast-glob": {
|
||||
"version": "3.2.11",
|
||||
@@ -17784,6 +17514,11 @@
|
||||
"semver": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"marked": {
|
||||
"version": "16.4.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
||||
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="
|
||||
},
|
||||
"mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
@@ -18970,60 +18705,6 @@
|
||||
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||
"dev": true
|
||||
},
|
||||
"radix-vue": {
|
||||
"version": "1.9.17",
|
||||
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
|
||||
"integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==",
|
||||
"requires": {
|
||||
"@floating-ui/dom": "^1.6.7",
|
||||
"@floating-ui/vue": "^1.1.0",
|
||||
"@internationalized/date": "^3.5.4",
|
||||
"@internationalized/number": "^3.5.3",
|
||||
"@tanstack/vue-virtual": "^3.8.1",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/shared": "^10.11.0",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"defu": "^6.1.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"nanoid": "^5.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
|
||||
},
|
||||
"@vueuse/core": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
|
||||
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
|
||||
"requires": {
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@vueuse/metadata": "10.11.1",
|
||||
"@vueuse/shared": "10.11.1",
|
||||
"vue-demi": ">=0.14.8"
|
||||
}
|
||||
},
|
||||
"@vueuse/metadata": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
|
||||
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="
|
||||
},
|
||||
"@vueuse/shared": {
|
||||
"version": "10.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
|
||||
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
|
||||
"requires": {
|
||||
"vue-demi": ">=0.14.8"
|
||||
}
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
|
||||
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -19954,7 +19635,8 @@
|
||||
"tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true
|
||||
},
|
||||
"type-check": {
|
||||
"version": "0.4.0",
|
||||
@@ -20092,10 +19774,10 @@
|
||||
"@vue/shared": "3.5.18"
|
||||
}
|
||||
},
|
||||
"vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"vue-chartjs": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
|
||||
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
|
||||
"requires": {}
|
||||
},
|
||||
"vue-eslint-parser": {
|
||||
|
||||
@@ -8,22 +8,29 @@
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.45.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.0",
|
||||
"lucide-vue-next": "^0.539.0",
|
||||
"marked": "^16.4.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.25.1",
|
||||
"@vue/cli-plugin-babel": "^5.0.8",
|
||||
"@vue/cli-plugin-eslint": "^5.0.8",
|
||||
"@vue/cli-plugin-router": "^5.0.8",
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"@vue/compiler-sfc": "^3.5.18",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"@babel/eslint-parser": "^7.25.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script type="text/javascript">
|
||||
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||
window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||
// Initialize theme immediately to prevent flash
|
||||
(function() {
|
||||
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
|
||||
|
||||
+40
-5
@@ -148,7 +148,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<Tooltip :result="tooltip.result" :event="tooltip.event" />
|
||||
<Tooltip :result="tooltip.result" :event="tooltip.event" :isPersistent="tooltipIsPersistent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -173,6 +173,7 @@ const announcements = ref([])
|
||||
const tooltip = ref({})
|
||||
const mobileMenuOpen = ref(false)
|
||||
const isOidcLoading = ref(false)
|
||||
const tooltipIsPersistent = ref(false)
|
||||
let configInterval = null
|
||||
|
||||
// Computed properties
|
||||
@@ -196,21 +197,51 @@ const buttons = computed(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
|
||||
retrievedConfig.value = true
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json()
|
||||
config.value = data
|
||||
announcements.value = data.announcements || []
|
||||
}
|
||||
retrievedConfig.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
retrievedConfig.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const showTooltip = (result, event) => {
|
||||
tooltip.value = { result, event }
|
||||
const showTooltip = (result, event, action = 'hover') => {
|
||||
if (action === 'click') {
|
||||
if (!result) {
|
||||
// Deselecting
|
||||
tooltip.value = {}
|
||||
tooltipIsPersistent.value = false
|
||||
} else {
|
||||
// Selecting new data point
|
||||
tooltip.value = { result, event }
|
||||
tooltipIsPersistent.value = true
|
||||
}
|
||||
} else if (action === 'hover') {
|
||||
// Only update tooltip on hover if not in persistent mode
|
||||
if (!tooltipIsPersistent.value) {
|
||||
tooltip.value = { result, event }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDocumentClick = (event) => {
|
||||
// Close persistent tooltip when clicking outside
|
||||
if (tooltipIsPersistent.value) {
|
||||
const tooltipElement = document.getElementById('tooltip')
|
||||
// Check if click is on a data point bar or inside tooltip
|
||||
const clickedDataPoint = event.target.closest('.flex-1.h-6, .flex-1.h-8')
|
||||
|
||||
if (tooltipElement && !tooltipElement.contains(event.target) && !clickedDataPoint) {
|
||||
tooltip.value = {}
|
||||
tooltipIsPersistent.value = false
|
||||
// Emit event to clear selections in child components
|
||||
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch config on mount and set up interval
|
||||
@@ -218,6 +249,8 @@ onMounted(() => {
|
||||
fetchConfig()
|
||||
// Refresh config every 10 minutes for announcements
|
||||
configInterval = setInterval(fetchConfig, 600000)
|
||||
// Add click listener for closing persistent tooltips
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
// Clean up interval on unmount
|
||||
@@ -226,5 +259,7 @@ onUnmounted(() => {
|
||||
clearInterval(configInterval)
|
||||
configInterval = null
|
||||
}
|
||||
// Remove click listener
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
})
|
||||
</script>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -44,17 +44,10 @@
|
||||
:key="date"
|
||||
class="relative"
|
||||
>
|
||||
<!-- Vertical line from date to last icon -->
|
||||
<div
|
||||
v-if="group.length > 0"
|
||||
class="absolute left-3 w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
|
||||
:style="getTimelineHeight(group)"
|
||||
></div>
|
||||
|
||||
<!-- Date Header -->
|
||||
<div class="flex items-center gap-3 mb-2 relative">
|
||||
<div class="relative z-10 bg-white dark:bg-gray-800 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<time class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
<time class="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ formatDate(date) }}
|
||||
</time>
|
||||
</div>
|
||||
@@ -69,38 +62,60 @@
|
||||
class="relative"
|
||||
>
|
||||
<!-- Timeline Icon -->
|
||||
<div
|
||||
<div
|
||||
:class="[
|
||||
'absolute -left-[26px] top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
|
||||
'absolute -left-[26px] w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
|
||||
index === group.length - 1 ? 'top-3' : 'top-1/2 -translate-y-1/2',
|
||||
getTypeClasses(announcement.type).border
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="getTypeIcon(announcement.type)"
|
||||
<component
|
||||
:is="getTypeIcon(announcement.type)"
|
||||
:class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Vertical line segment connecting upward from first icon to date -->
|
||||
<div
|
||||
v-if="index === 0"
|
||||
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
|
||||
style="left: -16px; top: -2.5rem; height: calc(50% + 2.5rem);"
|
||||
></div>
|
||||
|
||||
<!-- Vertical line segment connecting downward to next icon -->
|
||||
<div
|
||||
v-if="index < group.length - 1"
|
||||
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
|
||||
:style="{
|
||||
left: '-16px',
|
||||
top: '50%',
|
||||
height: index === group.length - 2 ? 'calc(50% + 1.25rem)' : 'calc(50% + 2rem)'
|
||||
}"
|
||||
></div>
|
||||
|
||||
<!-- Announcement Card -->
|
||||
<div
|
||||
<div
|
||||
:class="[
|
||||
'rounded-md border p-3 transition-all duration-200 hover:shadow-sm',
|
||||
getTypeClasses(announcement.type).background
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">{{ announcement.message }}</p>
|
||||
</div>
|
||||
<time
|
||||
<div class="flex items-center gap-3">
|
||||
<time
|
||||
:class="[
|
||||
'text-xs font-mono whitespace-nowrap',
|
||||
'text-sm font-mono whitespace-nowrap flex-shrink-0',
|
||||
getTypeClasses(announcement.type).text
|
||||
]"
|
||||
:title="formatFullTimestamp(announcement.timestamp)"
|
||||
>
|
||||
{{ formatTime(announcement.timestamp) }}
|
||||
</time>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
|
||||
v-html="formatAnnouncementMessage(announcement.message)"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,6 +131,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
|
||||
import { formatAnnouncementMessage } from '@/utils/markdown'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
@@ -220,14 +236,6 @@ const getTypeClasses = (type) => {
|
||||
return typeConfigs[type] || typeConfigs.none
|
||||
}
|
||||
|
||||
const getTimelineHeight = (group) => {
|
||||
const height = group.length === 1 ? '2rem' : `${2 + (group.length - 1) * 3.5}rem`
|
||||
return {
|
||||
top: '1.5rem',
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
@@ -291,4 +299,4 @@ const formatFullTimestamp = (timestamp) => {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{{ endpoint.name }}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
||||
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground min-h-[1.25rem]">
|
||||
<span v-if="endpoint.group" class="truncate" :title="endpoint.group">{{ endpoint.group }}</span>
|
||||
<span v-if="endpoint.group && hostname">•</span>
|
||||
<span v-if="hostname" class="truncate" :title="hostname">{{ hostname }}</span>
|
||||
@@ -39,10 +39,16 @@
|
||||
:key="index"
|
||||
:class="[
|
||||
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
|
||||
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
|
||||
result ? 'cursor-pointer' : '',
|
||||
result ? (
|
||||
result.success
|
||||
? (selectedResultIndex === index ? 'bg-green-700' : 'bg-green-500 hover:bg-green-700')
|
||||
: (selectedResultIndex === index ? 'bg-red-700' : 'bg-red-500 hover:bg-red-700')
|
||||
) : 'bg-gray-200 dark:bg-gray-700'
|
||||
]"
|
||||
@mouseenter="result && emit('showTooltip', result, $event)"
|
||||
@mouseleave="result && emit('showTooltip', null, $event)"
|
||||
@mouseenter="result && handleMouseEnter(result, $event)"
|
||||
@mouseleave="result && handleMouseLeave(result, $event)"
|
||||
@click.stop="result && handleClick(result, $event, index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
|
||||
@@ -57,7 +63,7 @@
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
@@ -82,6 +88,9 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
// Track selected data point
|
||||
const selectedResultIndex = ref(null)
|
||||
|
||||
const latestResult = computed(() => {
|
||||
if (!props.endpoint.results || props.endpoint.results.length === 0) {
|
||||
return null
|
||||
@@ -156,4 +165,38 @@ const newestResultTime = computed(() => {
|
||||
const navigateToDetails = () => {
|
||||
router.push(`/endpoints/${props.endpoint.key}`)
|
||||
}
|
||||
|
||||
const handleMouseEnter = (result, event) => {
|
||||
emit('showTooltip', result, event, 'hover')
|
||||
}
|
||||
|
||||
const handleMouseLeave = (result, event) => {
|
||||
emit('showTooltip', null, event, 'hover')
|
||||
}
|
||||
|
||||
const handleClick = (result, event, index) => {
|
||||
// Clear selections in other cards first
|
||||
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
|
||||
// Then toggle this card's selection
|
||||
if (selectedResultIndex.value === index) {
|
||||
selectedResultIndex.value = null
|
||||
emit('showTooltip', null, event, 'click')
|
||||
} else {
|
||||
selectedResultIndex.value = index
|
||||
emit('showTooltip', result, event, 'click')
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for clear selection event
|
||||
const handleClearSelection = () => {
|
||||
selectedResultIndex.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('clear-data-point-selection', handleClearSelection)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('clear-data-point-selection', handleClearSelection)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div v-if="announcements && announcements.length" class="past-announcements">
|
||||
<h2 class="text-2xl font-semibold text-foreground mb-6">Past Announcements</h2>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div
|
||||
v-for="(group, date) in displayedAnnouncements"
|
||||
:key="date"
|
||||
>
|
||||
<!-- Date Header -->
|
||||
<div class="mb-3">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{{ formatDate(date) }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Announcements for this date or empty state -->
|
||||
<div v-if="group.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(announcement, index) in group"
|
||||
:key="`${date}-${index}-${announcement.timestamp}`"
|
||||
:class="[
|
||||
'border-l-4 p-4 transition-all duration-200',
|
||||
getTypeClasses(announcement.type).background,
|
||||
getTypeClasses(announcement.type).borderColor
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<component
|
||||
:is="getTypeIcon(announcement.type)"
|
||||
:class="['w-5 h-5 flex-shrink-0 mt-0.5', getTypeClasses(announcement.type).iconColor]"
|
||||
/>
|
||||
<time
|
||||
:class="[
|
||||
'text-sm font-mono whitespace-nowrap flex-shrink-0 mt-0.5',
|
||||
getTypeClasses(announcement.type).text
|
||||
]"
|
||||
:title="formatFullTimestamp(announcement.timestamp)"
|
||||
>
|
||||
{{ formatTime(announcement.timestamp) }}
|
||||
</time>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
|
||||
v-html="formatAnnouncementMessage(announcement.message)"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for dates without announcements -->
|
||||
<div v-else class="py-2">
|
||||
<p class="text-sm italic text-muted-foreground/60">
|
||||
No incidents reported on this day
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Older Announcements Link -->
|
||||
<div v-if="hasOlderAnnouncements && !showAllAnnouncements">
|
||||
<button @click="showAllAnnouncements = true" class="inline-flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer group">
|
||||
<ChevronDown class="w-4 h-4 group-hover:translate-y-0.5 transition-transform duration-200" />
|
||||
<span class="group-hover:underline">View older announcements</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
|
||||
import { formatAnnouncementMessage } from '@/utils/markdown'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
announcements: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// State
|
||||
const showAllAnnouncements = ref(false)
|
||||
|
||||
// Type configurations (consistent with AnnouncementBanner)
|
||||
const typeConfigs = {
|
||||
outage: {
|
||||
icon: XCircle,
|
||||
background: 'bg-red-50 dark:bg-red-900/20',
|
||||
borderColor: 'border-red-500 dark:border-red-400',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
text: 'text-red-700 dark:text-red-300'
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
background: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
borderColor: 'border-yellow-500 dark:border-yellow-400',
|
||||
iconColor: 'text-yellow-600 dark:text-yellow-400',
|
||||
text: 'text-yellow-700 dark:text-yellow-300'
|
||||
},
|
||||
information: {
|
||||
icon: Info,
|
||||
background: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
borderColor: 'border-blue-500 dark:border-blue-400',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
text: 'text-blue-700 dark:text-blue-300'
|
||||
},
|
||||
operational: {
|
||||
icon: CheckCircle,
|
||||
background: 'bg-green-50 dark:bg-green-900/20',
|
||||
borderColor: 'border-green-500 dark:border-green-400',
|
||||
iconColor: 'text-green-600 dark:text-green-400',
|
||||
text: 'text-green-700 dark:text-green-300'
|
||||
},
|
||||
none: {
|
||||
icon: Circle,
|
||||
background: 'bg-gray-50 dark:bg-gray-800/20',
|
||||
borderColor: 'border-gray-500 dark:border-gray-400',
|
||||
iconColor: 'text-gray-600 dark:text-gray-400',
|
||||
text: 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to normalize date to start of day
|
||||
const normalizeDate = (date) => {
|
||||
const normalized = new Date(date)
|
||||
normalized.setHours(0, 0, 0, 0)
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
const displayedAnnouncements = computed(() => {
|
||||
if (!props.announcements?.length) return {}
|
||||
|
||||
// Group announcements by date and find oldest
|
||||
const grouped = {}
|
||||
let oldest = new Date()
|
||||
|
||||
props.announcements.forEach(announcement => {
|
||||
const date = new Date(announcement.timestamp)
|
||||
const key = date.toDateString()
|
||||
grouped[key] = grouped[key] || []
|
||||
grouped[key].push(announcement)
|
||||
if (date < oldest) oldest = date
|
||||
})
|
||||
|
||||
// Calculate date range
|
||||
const today = normalizeDate(new Date())
|
||||
const endDate = showAllAnnouncements.value
|
||||
? normalizeDate(oldest)
|
||||
: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000)
|
||||
|
||||
// Build result: today (if has announcements) + yesterday backwards
|
||||
const result = {}
|
||||
const todayKey = today.toDateString()
|
||||
if (grouped[todayKey]) result[todayKey] = grouped[todayKey]
|
||||
|
||||
for (let date = new Date(today.getTime() - 24 * 60 * 60 * 1000); date >= endDate; date.setDate(date.getDate() - 1)) {
|
||||
result[date.toDateString()] = grouped[date.toDateString()] || []
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Check if there are announcements older than 14 days
|
||||
const hasOlderAnnouncements = computed(() => {
|
||||
if (!props.announcements?.length) return false
|
||||
const fourteenDaysAgo = new Date(normalizeDate(new Date()).getTime() - 14 * 24 * 60 * 60 * 1000)
|
||||
return props.announcements.some(a => new Date(a.timestamp) < fourteenDaysAgo)
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const getTypeIcon = (type) => {
|
||||
return typeConfigs[type]?.icon || Circle
|
||||
}
|
||||
|
||||
const getTypeClasses = (type) => {
|
||||
return typeConfigs[type] || typeConfigs.none
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
const formatFullTimestamp = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="relative w-full" style="height: 300px;">
|
||||
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Loading />
|
||||
</div>
|
||||
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
{{ error }}
|
||||
</div>
|
||||
<Line v-else :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale } from 'chart.js'
|
||||
import annotationPlugin from 'chartjs-plugin-annotation'
|
||||
import 'chartjs-adapter-date-fns'
|
||||
import { generatePrettyTimeDifference } from '@/utils/time'
|
||||
import Loading from './Loading.vue'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale, annotationPlugin)
|
||||
|
||||
const props = defineProps({
|
||||
endpointKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
duration: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['24h', '7d', '30d'].includes(value)
|
||||
},
|
||||
serverUrl: {
|
||||
type: String,
|
||||
default: '..'
|
||||
},
|
||||
events: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const timestamps = ref([])
|
||||
const values = ref([])
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
const hoveredEventIndex = ref(null)
|
||||
|
||||
// Helper function to get color for unhealthy events
|
||||
const getEventColor = () => {
|
||||
// Only UNHEALTHY events are displayed on the chart
|
||||
return 'rgba(239, 68, 68, 0.8)' // Red
|
||||
}
|
||||
|
||||
// Filter events based on selected duration and calculate durations
|
||||
const filteredEvents = computed(() => {
|
||||
if (!props.events || props.events.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let fromTime
|
||||
switch (props.duration) {
|
||||
case '24h':
|
||||
fromTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '7d':
|
||||
fromTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '30d':
|
||||
fromTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
default:
|
||||
return []
|
||||
}
|
||||
|
||||
// Only include UNHEALTHY events and calculate their duration
|
||||
const unhealthyEvents = []
|
||||
for (let i = 0; i < props.events.length; i++) {
|
||||
const event = props.events[i]
|
||||
if (event.type !== 'UNHEALTHY') continue
|
||||
|
||||
const eventTime = new Date(event.timestamp)
|
||||
if (eventTime < fromTime || eventTime > now) continue
|
||||
|
||||
// Find the next event to calculate duration
|
||||
let duration = null
|
||||
let isOngoing = false
|
||||
if (i + 1 < props.events.length) {
|
||||
const nextEvent = props.events[i + 1]
|
||||
duration = generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
|
||||
} else {
|
||||
// Still ongoing - calculate duration from event time to now
|
||||
duration = generatePrettyTimeDifference(now, event.timestamp)
|
||||
isOngoing = true
|
||||
}
|
||||
|
||||
unhealthyEvents.push({
|
||||
...event,
|
||||
duration,
|
||||
isOngoing
|
||||
})
|
||||
}
|
||||
|
||||
return unhealthyEvents
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (timestamps.value.length === 0) {
|
||||
return {
|
||||
labels: [],
|
||||
datasets: []
|
||||
}
|
||||
}
|
||||
const labels = timestamps.value.map(ts => new Date(ts))
|
||||
return {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Response Time (ms)',
|
||||
data: values.value,
|
||||
borderColor: isDark.value ? 'rgb(96, 165, 250)' : 'rgb(59, 130, 246)',
|
||||
backgroundColor: isDark.value ? 'rgba(96, 165, 250, 0.1)' : 'rgba(59, 130, 246, 0.1)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
// Include hoveredEventIndex in dependency tracking
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const _ = hoveredEventIndex.value
|
||||
|
||||
// Calculate max Y value for positioning annotations
|
||||
const maxY = values.value.length > 0 ? Math.max(...values.value) : 0
|
||||
const midY = maxY / 2
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark.value ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
||||
titleColor: isDark.value ? '#f9fafb' : '#111827',
|
||||
bodyColor: isDark.value ? '#d1d5db' : '#374151',
|
||||
borderColor: isDark.value ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (tooltipItems) => {
|
||||
if (tooltipItems.length > 0) {
|
||||
const date = new Date(tooltipItems[0].parsed.x)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
return ''
|
||||
},
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
return `${value}ms`
|
||||
}
|
||||
}
|
||||
},
|
||||
annotation: {
|
||||
annotations: filteredEvents.value.reduce((acc, event, index) => {
|
||||
// Find closest data point to determine annotation position
|
||||
const eventTimestamp = new Date(event.timestamp).getTime()
|
||||
let closestValue = 0
|
||||
|
||||
if (timestamps.value.length > 0 && values.value.length > 0) {
|
||||
const closestIndex = timestamps.value.reduce((closest, ts, idx) => {
|
||||
const tsTime = new Date(ts).getTime()
|
||||
const currentDistance = Math.abs(tsTime - eventTimestamp)
|
||||
const closestDistance = Math.abs(new Date(timestamps.value[closest]).getTime() - eventTimestamp)
|
||||
return currentDistance < closestDistance ? idx : closest
|
||||
}, 0)
|
||||
closestValue = values.value[closestIndex]
|
||||
}
|
||||
|
||||
// Position annotation at bottom if data point is in lower half, at top if in upper half
|
||||
const position = closestValue <= midY ? 'end' : 'start'
|
||||
|
||||
acc[`event-${index}`] = {
|
||||
type: 'line',
|
||||
xMin: new Date(event.timestamp),
|
||||
xMax: new Date(event.timestamp),
|
||||
borderColor: getEventColor(),
|
||||
borderWidth: 1,
|
||||
borderDash: [5, 5],
|
||||
enter() {
|
||||
hoveredEventIndex.value = index
|
||||
},
|
||||
leave() {
|
||||
hoveredEventIndex.value = null
|
||||
},
|
||||
label: {
|
||||
display: () => hoveredEventIndex.value === index,
|
||||
content: [event.isOngoing ? `Status: ONGOING` : `Status: RESOLVED`, `Unhealthy for ${event.duration}`, `Started at ${new Date(event.timestamp).toLocaleString()}`],
|
||||
backgroundColor: getEventColor(),
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
padding: 6,
|
||||
position
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: props.duration === '24h' ? 'hour' : props.duration === '7d' ? 'day' : 'day',
|
||||
displayFormats: {
|
||||
hour: 'MMM d, ha',
|
||||
day: 'MMM d'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: isDark.value ? '#9ca3af' : '#6b7280',
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 20
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: isDark.value ? '#9ca3af' : '#6b7280',
|
||||
callback: (value) => `${value}ms`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await fetch(`${props.serverUrl}/api/v1/endpoints/${props.endpointKey}/response-times/${props.duration}/history`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (response.status === 200) {
|
||||
const data = await response.json()
|
||||
timestamps.value = data.timestamps || []
|
||||
values.value = data.values || []
|
||||
} else {
|
||||
error.value = 'Failed to load chart data'
|
||||
console.error('[ResponseTimeChart] Error:', await response.text())
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load chart data'
|
||||
console.error('[ResponseTimeChart] Error:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.duration, () => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
const observer = new MutationObserver(() => {
|
||||
isDark.value = document.documentElement.classList.contains('dark')
|
||||
})
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
onUnmounted(() => observer.disconnect())
|
||||
})
|
||||
</script>
|
||||
@@ -39,15 +39,21 @@
|
||||
:key="index"
|
||||
:class="[
|
||||
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
|
||||
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
|
||||
result ? 'cursor-pointer' : '',
|
||||
result ? (
|
||||
result.success
|
||||
? (selectedResultIndex === index ? 'bg-green-700' : 'bg-green-500 hover:bg-green-700')
|
||||
: (selectedResultIndex === index ? 'bg-red-700' : 'bg-red-500 hover:bg-red-700')
|
||||
) : 'bg-gray-200 dark:bg-gray-700'
|
||||
]"
|
||||
@mouseenter="result && showTooltip(result, $event)"
|
||||
@mouseleave="hideTooltip($event)"
|
||||
@mouseenter="result && handleMouseEnter(result, $event)"
|
||||
@mouseleave="result && handleMouseLeave(result, $event)"
|
||||
@click.stop="result && handleClick(result, $event, index)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>{{ newestResultTime }}</span>
|
||||
<span>{{ oldestResultTime }}</span>
|
||||
<span>{{ newestResultTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
@@ -77,6 +83,9 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
// Track selected data point
|
||||
const selectedResultIndex = ref(null)
|
||||
|
||||
// Computed properties
|
||||
const displayResults = computed(() => {
|
||||
const results = [...(props.suite.results || [])]
|
||||
@@ -143,13 +152,39 @@ const navigateToDetails = () => {
|
||||
router.push(`/suites/${props.suite.key}`)
|
||||
}
|
||||
|
||||
const showTooltip = (result, event) => {
|
||||
emit('showTooltip', result, event)
|
||||
const handleMouseEnter = (result, event) => {
|
||||
emit('showTooltip', result, event, 'hover')
|
||||
}
|
||||
|
||||
const hideTooltip = (event) => {
|
||||
emit('showTooltip', null, event)
|
||||
const handleMouseLeave = (result, event) => {
|
||||
emit('showTooltip', null, event, 'hover')
|
||||
}
|
||||
|
||||
const handleClick = (result, event, index) => {
|
||||
// Clear selections in other cards first
|
||||
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
|
||||
// Then toggle this card's selection
|
||||
if (selectedResultIndex.value === index) {
|
||||
selectedResultIndex.value = null
|
||||
emit('showTooltip', null, event, 'click')
|
||||
} else {
|
||||
selectedResultIndex.value = index
|
||||
emit('showTooltip', result, event, 'click')
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for clear selection event
|
||||
const handleClearSelection = () => {
|
||||
selectedResultIndex.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('clear-data-point-selection', handleClearSelection)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('clear-data-point-selection', handleClearSelection)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
id="tooltip"
|
||||
ref="tooltip"
|
||||
<div
|
||||
id="tooltip"
|
||||
ref="tooltip"
|
||||
:class="[
|
||||
'fixed z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
|
||||
'absolute z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
|
||||
'bg-popover text-popover-foreground border-border',
|
||||
hidden ? 'invisible opacity-0' : 'visible opacity-100'
|
||||
]"
|
||||
]"
|
||||
:style="`top: ${top}px; left: ${left}px;`"
|
||||
>
|
||||
<div v-if="result" class="space-y-2">
|
||||
@@ -96,9 +96,12 @@
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { prettifyTimestamp } from '@/utils/time'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: [Event, Object],
|
||||
@@ -107,6 +110,10 @@ const props = defineProps({
|
||||
result: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
isPersistent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -115,6 +122,7 @@ const hidden = ref(true)
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const tooltip = ref(null)
|
||||
const targetElement = ref(null)
|
||||
|
||||
// Computed properties
|
||||
const isSuiteResult = computed(() => {
|
||||
@@ -133,75 +141,109 @@ const successCount = computed(() => {
|
||||
|
||||
// Methods are imported from utils/time
|
||||
|
||||
// Update tooltip position based on target element's current position
|
||||
const updatePosition = async () => {
|
||||
if (!targetElement.value || !tooltip.value || hidden.value) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
const targetRect = targetElement.value.getBoundingClientRect()
|
||||
const tooltipRect = tooltip.value.getBoundingClientRect()
|
||||
|
||||
// For absolute positioning, we need to add scroll offsets
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft
|
||||
|
||||
// Default position: below the target (viewport coords + scroll offset)
|
||||
let newTop = targetRect.bottom + scrollTop + 8
|
||||
let newLeft = targetRect.left + scrollLeft
|
||||
|
||||
// Check if tooltip would overflow the viewport bottom
|
||||
const spaceBelow = window.innerHeight - targetRect.bottom
|
||||
const spaceAbove = targetRect.top
|
||||
|
||||
if (spaceBelow < tooltipRect.height + 20) {
|
||||
// Not enough space below, try above
|
||||
if (spaceAbove > tooltipRect.height + 20) {
|
||||
// Position above
|
||||
newTop = targetRect.top + scrollTop - tooltipRect.height - 8
|
||||
} else {
|
||||
// Not enough space above either, position at the best spot
|
||||
if (spaceAbove > spaceBelow) {
|
||||
// More space above
|
||||
newTop = scrollTop + 10
|
||||
} else {
|
||||
// More space below or equal, keep below but adjust
|
||||
newTop = scrollTop + window.innerHeight - tooltipRect.height - 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust horizontal position if tooltip would overflow right edge
|
||||
const spaceRight = window.innerWidth - targetRect.left
|
||||
if (spaceRight < tooltipRect.width + 20) {
|
||||
// Align right edge of tooltip with right edge of target
|
||||
newLeft = targetRect.right + scrollLeft - tooltipRect.width
|
||||
// Make sure it doesn't go off the left edge
|
||||
if (newLeft < scrollLeft + 10) {
|
||||
newLeft = scrollLeft + 10
|
||||
}
|
||||
}
|
||||
|
||||
top.value = Math.round(newTop)
|
||||
left.value = Math.round(newLeft)
|
||||
}
|
||||
|
||||
const reposition = async () => {
|
||||
if (!props.event || !props.event.type) return
|
||||
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (props.event.type === 'mouseenter' && tooltip.value) {
|
||||
|
||||
if ((props.event.type === 'mouseenter' || props.event.type === 'click') && tooltip.value) {
|
||||
const target = props.event.target
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
|
||||
// First, position tooltip to get its dimensions
|
||||
// Store the target element for scroll updates
|
||||
targetElement.value = target
|
||||
|
||||
// First, make tooltip visible to get its dimensions
|
||||
hidden.value = false
|
||||
await nextTick()
|
||||
|
||||
const tooltipRect = tooltip.value.getBoundingClientRect()
|
||||
|
||||
// Since tooltip uses position: fixed, we work with viewport coordinates
|
||||
// getBoundingClientRect() already gives us viewport-relative positions
|
||||
|
||||
// Default position: below the target
|
||||
let newTop = targetRect.bottom + 8
|
||||
let newLeft = targetRect.left
|
||||
|
||||
// Check if tooltip would overflow the viewport bottom
|
||||
const spaceBelow = window.innerHeight - targetRect.bottom
|
||||
const spaceAbove = targetRect.top
|
||||
|
||||
if (spaceBelow < tooltipRect.height + 20) {
|
||||
// Not enough space below, try above
|
||||
if (spaceAbove > tooltipRect.height + 20) {
|
||||
// Position above
|
||||
newTop = targetRect.top - tooltipRect.height - 8
|
||||
} else {
|
||||
// Not enough space above either, position at the best spot
|
||||
if (spaceAbove > spaceBelow) {
|
||||
// More space above
|
||||
newTop = 10
|
||||
} else {
|
||||
// More space below or equal, keep below but adjust
|
||||
newTop = window.innerHeight - tooltipRect.height - 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust horizontal position if tooltip would overflow right edge
|
||||
const spaceRight = window.innerWidth - targetRect.left
|
||||
if (spaceRight < tooltipRect.width + 20) {
|
||||
// Align right edge of tooltip with right edge of target
|
||||
newLeft = targetRect.right - tooltipRect.width
|
||||
// Make sure it doesn't go off the left edge
|
||||
if (newLeft < 10) {
|
||||
newLeft = 10
|
||||
}
|
||||
}
|
||||
|
||||
top.value = Math.round(newTop)
|
||||
left.value = Math.round(newLeft)
|
||||
|
||||
// Update position
|
||||
await updatePosition()
|
||||
} else if (props.event.type === 'mouseleave') {
|
||||
hidden.value = true
|
||||
// Only hide on mouseleave if not in persistent mode
|
||||
if (!props.isPersistent) {
|
||||
hidden.value = true
|
||||
targetElement.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle resize events (still needed for viewport size changes)
|
||||
const handleResize = () => {
|
||||
updatePosition()
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(() => props.event, (newEvent) => {
|
||||
if (newEvent && newEvent.type) {
|
||||
if (newEvent.type === 'mouseenter') {
|
||||
if (newEvent.type === 'mouseenter' || newEvent.type === 'click') {
|
||||
hidden.value = false
|
||||
nextTick(() => reposition())
|
||||
} else if (newEvent.type === 'mouseleave') {
|
||||
hidden.value = true
|
||||
// Only hide on mouseleave if not in persistent mode
|
||||
if (!props.isPersistent) {
|
||||
hidden.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
@@ -211,4 +253,22 @@ watch(() => props.result, () => {
|
||||
nextTick(() => reposition())
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for persistent state changes and result changes
|
||||
watch(() => [props.isPersistent, props.result], ([isPersistent, result]) => {
|
||||
if (!isPersistent && !result) {
|
||||
// Hide tooltip when both persistent mode is off and no result
|
||||
hidden.value = true
|
||||
} else if (result && (isPersistent || props.event?.type === 'mouseenter')) {
|
||||
// Show tooltip when there's a result and either persistent or hovering
|
||||
hidden.value = false
|
||||
nextTick(() => reposition())
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for route changes and hide tooltip
|
||||
watch(() => route.path, () => {
|
||||
hidden.value = true
|
||||
targetElement.value = null
|
||||
})
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
|
||||
defineProps({
|
||||
variant: {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
|
||||
defineProps({
|
||||
variant: {
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
</script>
|
||||
@@ -5,5 +5,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
</script>
|
||||
@@ -5,5 +5,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
</script>
|
||||
@@ -5,5 +5,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
</script>
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-undef */
|
||||
import { combineClasses } from '@/lib/utils'
|
||||
import { combineClasses } from '@/utils/misc'
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const escapeHtml = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.link = (tokenOrHref, title, text) => {
|
||||
const tokenObject = typeof tokenOrHref === 'object' && tokenOrHref !== null
|
||||
? tokenOrHref
|
||||
: null
|
||||
const href = tokenObject ? tokenObject.href : tokenOrHref
|
||||
const resolvedTitle = tokenObject ? tokenObject.title : title
|
||||
const resolvedText = tokenObject ? tokenObject.text : text
|
||||
const url = escapeHtml(href || '')
|
||||
const titleAttribute = resolvedTitle ? ` title="${escapeHtml(resolvedTitle)}"` : ''
|
||||
const linkText = resolvedText || ''
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="text-blue-700 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline font-medium"${titleAttribute}>${linkText}</a>`
|
||||
}
|
||||
|
||||
marked.use({
|
||||
renderer,
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
headerIds: false,
|
||||
mangle: false
|
||||
})
|
||||
|
||||
export const formatAnnouncementMessage = (message) => {
|
||||
if (!message) {
|
||||
return ''
|
||||
}
|
||||
const markdown = String(message)
|
||||
const html = marked.parse(markdown)
|
||||
return DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] })
|
||||
}
|
||||
@@ -31,8 +31,28 @@ export const generatePrettyTimeAgo = (timestamp) => {
|
||||
* @returns {string} Time difference string
|
||||
*/
|
||||
export const generatePrettyTimeDifference = (start, end) => {
|
||||
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
|
||||
return minutes + (minutes === 1 ? ' minute' : ' minutes');
|
||||
const ms = new Date(start) - new Date(end)
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
const remainingMinutes = minutes % 60
|
||||
const hoursText = hours + (hours === 1 ? ' hour' : ' hours')
|
||||
if (remainingMinutes > 0) {
|
||||
return hoursText + ' ' + remainingMinutes + (remainingMinutes === 1 ? ' minute' : ' minutes')
|
||||
}
|
||||
return hoursText
|
||||
} else if (minutes > 0) {
|
||||
const remainingSeconds = seconds % 60
|
||||
const minutesText = minutes + (minutes === 1 ? ' minute' : ' minutes')
|
||||
if (remainingSeconds > 0) {
|
||||
return minutesText + ' ' + remainingSeconds + (remainingSeconds === 1 ? ' second' : ' seconds')
|
||||
}
|
||||
return minutesText
|
||||
} else {
|
||||
return seconds + (seconds === 1 ? ' second' : ' seconds')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,7 +117,13 @@
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="Response time chart" class="w-full" />
|
||||
<ResponseTimeChart
|
||||
v-if="endpointStatus && endpointStatus.key"
|
||||
:endpointKey="endpointStatus.key"
|
||||
:duration="selectedChartDuration"
|
||||
:serverUrl="serverUrl"
|
||||
:events="endpointStatus.events || []"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -206,6 +212,7 @@ import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import ResponseTimeChart from '@/components/ResponseTimeChart.vue'
|
||||
import { SERVER_URL } from '@/main.js'
|
||||
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
|
||||
|
||||
@@ -369,8 +376,8 @@ const changePage = (page) => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const showTooltip = (result, event) => {
|
||||
emit('showTooltip', result, event)
|
||||
const showTooltip = (result, event, action = 'hover') => {
|
||||
emit('showTooltip', result, event, action)
|
||||
}
|
||||
|
||||
const prettifyTimestamp = (timestamp) => {
|
||||
@@ -389,10 +396,6 @@ const generateResponseTimeBadgeImageURL = (duration) => {
|
||||
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
|
||||
}
|
||||
|
||||
const generateResponseTimeChartImageURL = (duration) => {
|
||||
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/chart.svg`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold tracking-tight">Health Dashboard</h1>
|
||||
<p class="text-muted-foreground mt-2">Monitor the health of your endpoints in real-time</p>
|
||||
<h1 class="text-4xl font-bold tracking-tight">{{ dashboardHeading }}</h1>
|
||||
<p class="text-muted-foreground mt-2">{{ dashboardSubheading }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
@@ -22,8 +22,8 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Announcement Banner -->
|
||||
<AnnouncementBanner :announcements="props.announcements" />
|
||||
<!-- Announcement Banner (Active Announcements) -->
|
||||
<AnnouncementBanner :announcements="activeAnnouncements" />
|
||||
<!-- Search bar -->
|
||||
<SearchBar
|
||||
@search="handleSearch"
|
||||
@@ -170,6 +170,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Past Announcements Section -->
|
||||
<div v-if="archivedAnnouncements.length > 0" class="mt-12 pb-8">
|
||||
<PastAnnouncements :announcements="archivedAnnouncements" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Settings @refreshData="fetchData" />
|
||||
@@ -187,6 +192,7 @@ import SearchBar from '@/components/SearchBar.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
|
||||
import PastAnnouncements from '@/components/PastAnnouncements.vue'
|
||||
import { SERVER_URL } from '@/main.js'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -196,6 +202,15 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
// Computed properties for active and archived announcements
|
||||
const activeAnnouncements = computed(() => {
|
||||
return props.announcements ? props.announcements.filter(a => !a.archived) : []
|
||||
})
|
||||
|
||||
const archivedAnnouncements = computed(() => {
|
||||
return props.announcements ? props.announcements.filter(a => a.archived) : []
|
||||
})
|
||||
|
||||
const emit = defineEmits(['showTooltip'])
|
||||
|
||||
const endpointStatuses = ref([])
|
||||
@@ -471,8 +486,8 @@ const toggleShowAverageResponseTime = () => {
|
||||
showAverageResponseTime.value = !showAverageResponseTime.value
|
||||
}
|
||||
|
||||
const showTooltip = (result, event) => {
|
||||
emit('showTooltip', result, event)
|
||||
const showTooltip = (result, event, action = 'hover') => {
|
||||
emit('showTooltip', result, event, action)
|
||||
}
|
||||
|
||||
const calculateUnhealthyCount = (endpoints) => {
|
||||
@@ -517,6 +532,14 @@ const initializeCollapsedGroups = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const dashboardHeading = computed(() => {
|
||||
return window.config && window.config.dashboardHeading && window.config.dashboardHeading !== '{{ .UI.DashboardHeading }}' ? window.config.dashboardHeading : "Health Dashboard"
|
||||
})
|
||||
|
||||
const dashboardSubheading = computed(() => {
|
||||
return window.config && window.config.dashboardSubheading && window.config.dashboardSubheading !== '{{ .UI.dashboardSubheading }}' ? window.config.dashboardSubheading : "Monitor the health of your endpoints in real-time"
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<p class="text-muted-foreground mt-2">
|
||||
<span v-if="suite?.group">{{ suite.group }} • </span>
|
||||
<span v-if="latestResult">
|
||||
{{ selectedResult && selectedResult !== sortedResults[0] ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}
|
||||
{{ selectedResult && selectedResult.timestamp !== sortedResults[0]?.timestamp ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<!-- Latest Execution -->
|
||||
<Card v-if="latestResult">
|
||||
<CardHeader>
|
||||
<CardTitle>Latest Execution</CardTitle>
|
||||
<CardTitle>{{ selectedResult?.timestamp === sortedResults[0]?.timestamp ? 'Latest Execution' : `Execution at ${formatTimestamp(selectedResult.timestamp)}` }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
@@ -107,7 +107,7 @@
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
@click="selectedResult = result"
|
||||
:class="{ 'bg-accent': selectedResult === result }"
|
||||
:class="{ 'bg-accent': selectedResult && selectedResult.timestamp === result.timestamp }"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<StatusBadge :status="result.success ? 'healthy' : 'unhealthy'" size="sm" />
|
||||
@@ -184,20 +184,30 @@ const latestResult = computed(() => {
|
||||
|
||||
// Methods
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
|
||||
// Don't show loading state on refresh to prevent UI flicker
|
||||
const isInitialLoad = !suite.value
|
||||
if (isInitialLoad) {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/suites/${route.params.key}/statuses`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = await response.json()
|
||||
const oldSuite = suite.value
|
||||
suite.value = data
|
||||
if (data.results && data.results.length > 0 && !selectedResult.value) {
|
||||
if (data.results && data.results.length > 0) {
|
||||
// Sort results by timestamp to get the most recent one
|
||||
const sorted = [...data.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
selectedResult.value = sorted[0]
|
||||
// Update selectedResult if: no result selected, or currently viewing the latest result
|
||||
const wasViewingLatest = !selectedResult.value ||
|
||||
(oldSuite?.results && selectedResult.value.timestamp === [...oldSuite.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]?.timestamp)
|
||||
if (wasViewingLatest) {
|
||||
selectedResult.value = sorted[0]
|
||||
}
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
suite.value = null
|
||||
@@ -207,7 +217,9 @@ const fetchData = async () => {
|
||||
} catch (error) {
|
||||
console.error('[SuiteDetails][fetchData] Error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (isInitialLoad) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||
<!doctype html><html lang="en" class="{{ .Theme }}"><head><meta charset="utf-8"/><script>window.config = {logo: "{{ .UI.Logo }}", header: "{{ .UI.Header }}", dashboardHeading: "{{ .UI.DashboardHeading }}", dashboardSubheading: "{{ .UI.DashboardSubheading }}", link: "{{ .UI.Link }}", buttons: [], maximumNumberOfResults: "{{ .UI.MaximumNumberOfResults }}", defaultSortBy: "{{ .UI.DefaultSortBy }}", defaultFilterBy: "{{ .UI.DefaultFilterBy }}"};{{- range .UI.Buttons}}window.config.buttons.push({name:"{{ .Name }}",link:"{{ .Link }}"});{{end}}
|
||||
// Initialize theme immediately to prevent flash
|
||||
(function() {
|
||||
const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user