mirror of
https://github.com/TwiN/gatus.git
synced 2026-08-22 07:06:04 +00:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0c9f81c8e | ||
|
|
a7f0aaf87a | ||
|
|
1087bf2fb2 | ||
|
|
fef2bb9bf6 | ||
|
|
e7510c856c | ||
|
|
5f4c26e5fe | ||
|
|
2beaca5700 | ||
|
|
a55eb7da40 | ||
|
|
b0629773e5 | ||
|
|
c5f7e5b82b | ||
|
|
a2a7e1f14a | ||
|
|
1e4c440f01 | ||
|
|
844847bb05 | ||
|
|
0c3231713f | ||
|
|
ee01adb603 | ||
|
|
9121d87965 | ||
|
|
86cd1a9eb2 | ||
|
|
9f960fdd27 | ||
|
|
6f9db4107c | ||
|
|
5d626f2934 | ||
|
|
75c1b290f6 | ||
|
|
fe7b74f555 | ||
|
|
ed4c270a25 | ||
|
|
379ec2983d | ||
|
|
907716289c | ||
|
|
7c6b5539c1 | ||
|
|
607f3c5549 | ||
|
|
9e97efaba1 | ||
|
|
8912b4b3e3 | ||
|
|
5fdc489113 | ||
|
|
2ebb74ae1e | ||
|
|
e2f06e9ede | ||
|
|
beb9a2f3d9 | ||
|
|
e469b6adf4 | ||
|
|
2f8a3d2a02 | ||
|
|
9495b7389e | ||
|
|
c8bdecbde8 | ||
|
|
394602bc47 | ||
|
|
15813d4297 | ||
|
|
d24c66cf96 | ||
|
|
70d7d0c54c | ||
|
|
91931e48b4 | ||
|
|
386a4d2cb7 | ||
|
|
4d9eb0572c | ||
|
|
1586b3cc0b | ||
|
|
981e082d0c | ||
|
|
91daaf92aa | ||
|
|
a1bb07c556 | ||
|
|
258175dec3 | ||
|
|
ef6159e420 | ||
|
|
ebd4068aac | ||
|
|
39981de54b | ||
|
|
7dce07e47f | ||
|
|
6a83857db4 | ||
|
|
50702bd1d4 | ||
|
|
5bf95fe4f7 | ||
|
|
20d8ef966b | ||
|
|
8f15346fb7 | ||
|
|
8920bdd301 | ||
|
|
e37024dfc6 | ||
|
|
ac4374b1e3 | ||
|
|
129fb82f71 | ||
|
|
374be99b35 | ||
|
|
5c78bd92fb | ||
|
|
8853140cb2 | ||
|
|
03ec18a703 | ||
|
|
65eaed4621 | ||
|
|
10c6e71eef | ||
|
|
c7f0a32135 | ||
|
|
405c15f756 | ||
|
|
6f1312dfcf | ||
|
|
bd296c75da | ||
|
|
f007725140 |
+2
-3
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
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
|
||||
@@ -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"
|
||||
+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
|
||||
}
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
container_name: gatus
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:stable
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
gatus:
|
||||
image: twinproduction/gatus:latest
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
Executable
+1
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 89.76 89.75"><defs><style>.cls-1{fill:#3cad4b;}.cls-2{fill:#017400;}.cls-3{fill:#1e9025;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M33.67,65.35a23.35,23.35,0,0,1,.08-41,22.94,22.94,0,0,1,3.8-1.64A23,23,0,0,0,53.6,1C53,0,51,0,44.89,0c-9.08,0-9.21.17-8.81,3.22,1.07,8.12-9.42,12.5-14.45,6-1.94-2.52-2.1-2.52-8.68,4.16-6.22,6.3-6.33,6.28-3.77,8.25a8.09,8.09,0,0,1,2.56,9.53A8.15,8.15,0,0,1,3.08,36C0,35.63,0,35.73,0,45.2.08,53.81,0,54,3.3,53.63A8.06,8.06,0,0,1,9.76,67.52c-3,2.83-2.84,2.61,2.84,8.48,5.43,5.62,6.33,6.73,8.16,5.24L34,68A1.63,1.63,0,0,0,33.67,65.35Z"/><path class="cls-2" d="M85.43,36.13a8.11,8.11,0,0,1-5.27-14.21c2.85-2.5,2.82-2.37-3.55-8.75-4.31-4.31-5.71-5.75-6.87-5.4l-14,14a1.65,1.65,0,0,0,.36,2.61,23.35,23.35,0,0,1-.1,41,24.5,24.5,0,0,1-5.11,2c-8.54,2.28-14.73,9.63-14.73,18.47v1.27c.15,2.54,1.19,2.42,8.06,2.52,9.32.14,9.1.35,9.38-4.66a8.11,8.11,0,0,1,14-5.09c3,3.15,2.39,3.11,8.73-3.14,6.56-6.47,6.86-6.25,3.68-9.14a8.1,8.1,0,0,1,6.06-14.07c3.68.27,3.51.06,3.63-8.09C89.85,36.27,90,36.16,85.43,36.13Z"/><path class="cls-3" d="M41.11,59h8a.76.76,0,0,0,.77-.76V50.43a.76.76,0,0,1,.77-.76h7.84a.78.78,0,0,0,.77-.77V40.84a.77.77,0,0,0-.77-.76H50.7a.76.76,0,0,1-.77-.77V31.47a.76.76,0,0,0-.77-.77h-8a.76.76,0,0,0-.77.77v7.84a.76.76,0,0,1-.77.77H31.73a.77.77,0,0,0-.77.76V48.9a.78.78,0,0,0,.77.77h7.84a.76.76,0,0,1,.77.76v7.85A.76.76,0,0,0,41.11,59Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -15,4 +15,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: make frontend-install-dependencies
|
||||
- run: make frontend-build
|
||||
- run: make frontend-build
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Error: There are uncommitted changes after running 'make frontend-build'"
|
||||
echo "Please run 'make frontend-build' locally and commit the changes"
|
||||
echo ""
|
||||
echo "Changed files:"
|
||||
git diff --name-only
|
||||
echo ""
|
||||
git status
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v5.5.1
|
||||
uses: codecov/codecov-action@v5.5.2
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ FROM golang:alpine AS builder
|
||||
RUN apk --update add ca-certificates
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
RUN go mod tidy
|
||||
RUN go mod tidy -diff
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
||||
|
||||
# Run Tests inside docker image if you don't have a configured go environment
|
||||
|
||||
@@ -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,6 +50,7 @@ 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)
|
||||
@@ -66,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)
|
||||
@@ -94,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)
|
||||
@@ -108,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)
|
||||
@@ -118,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)
|
||||
@@ -185,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
|
||||
@@ -263,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. | `""` |
|
||||
@@ -311,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. | `{}` |
|
||||
@@ -368,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).
|
||||
- `{error}` (optional): a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful, this will be ignored.
|
||||
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
|
||||
|
||||
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:
|
||||
@@ -420,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
|
||||
@@ -434,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
|
||||
@@ -445,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
|
||||
@@ -456,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
|
||||
@@ -524,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)
|
||||
@@ -543,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 |
|
||||
@@ -708,7 +721,7 @@ 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.
|
||||
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.
|
||||
@@ -745,6 +758,9 @@ endpoints:
|
||||
- "[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
|
||||
@@ -809,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). | `{}` |
|
||||
@@ -919,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 | `""` |
|
||||
@@ -1298,7 +1315,7 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
#### Configuring ilert alerts
|
||||
#### Configuring Ilert alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
|
||||
@@ -1380,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 |
|
||||
@@ -1576,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"
|
||||
@@ -1587,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 |
|
||||
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
|
||||
@@ -1840,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.
|
||||
@@ -1906,40 +1967,6 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
#### Configuring SendGrid alerts
|
||||
|
||||
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.sendgrid` | Configuration for alerts of type `sendgrid` | `{}` |
|
||||
| `alerting.sendgrid.api-key` | SendGrid API key | Required `""` |
|
||||
| `alerting.sendgrid.from` | Email address to send from | Required `""` |
|
||||
| `alerting.sendgrid.to` | Email address(es) to send alerts to (comma-separated for multiple recipients) | Required `""` |
|
||||
| `alerting.sendgrid.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.sendgrid.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.sendgrid.overrides[].*` | See `alerting.sendgrid.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
sendgrid:
|
||||
api-key: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
from: "alerts@example.com"
|
||||
to: "admin@example.com,ops@example.com"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: sendgrid
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Slack alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
|
||||
@@ -2167,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 |
|
||||
@@ -2286,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:
|
||||
@@ -2345,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 `""` |
|
||||
@@ -2401,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
|
||||
@@ -2662,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.
|
||||
@@ -2739,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
|
||||
```
|
||||
|
||||
|
||||
@@ -2776,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
|
||||
@@ -2821,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.
|
||||
|
||||
@@ -2917,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
|
||||
@@ -2947,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:
|
||||
@@ -2975,23 +3048,39 @@ There are two placeholders that can be used in the conditions for endpoints of t
|
||||
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: ssh-example
|
||||
# Password-based SSH example
|
||||
- name: ssh-example-password
|
||||
url: "ssh://example.com:22" # port is optional. Default is 22.
|
||||
ssh:
|
||||
username: "username"
|
||||
password: "password"
|
||||
body: |
|
||||
{
|
||||
"command": "uptime"
|
||||
"command": "echo '{\"memory\": {\"used\": 512}}'"
|
||||
}
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[STATUS] == 0"
|
||||
- "[BODY].memory.used > 500"
|
||||
|
||||
# Key-based SSH example
|
||||
- name: ssh-example-key
|
||||
url: "ssh://example.com:22" # port is optional. Default is 22.
|
||||
ssh:
|
||||
username: "username"
|
||||
private-key: |
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
TESTRSAKEY...
|
||||
-----END RSA PRIVATE KEY-----
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[STATUS] == 0"
|
||||
```
|
||||
|
||||
you can also use no authentication to monitor the endpoint by not specifying the username
|
||||
and password fields.
|
||||
you can also use no authentication to monitor the endpoint by not specifying the username,
|
||||
password and private key fields.
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
@@ -3000,6 +3089,7 @@ endpoints:
|
||||
ssh:
|
||||
username: ""
|
||||
password: ""
|
||||
private-key: ""
|
||||
|
||||
interval: 1m
|
||||
conditions:
|
||||
@@ -3010,6 +3100,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
|
||||
|
||||
@@ -3063,7 +3154,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`.
|
||||
@@ -3092,7 +3183,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:**
|
||||
@@ -3176,7 +3267,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
|
||||
@@ -3282,7 +3373,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:
|
||||
@@ -3308,7 +3399,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:
|
||||
@@ -3325,7 +3416,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:
|
||||
@@ -3348,7 +3439,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)
|
||||

|
||||
@@ -3361,7 +3452,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.
|
||||
@@ -3419,7 +3510,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:
|
||||
```
|
||||
@@ -3433,7 +3524,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:
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+51
-19
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -246,7 +248,7 @@ func CanPerformTLS(address string, body string, config *Config) (connected bool,
|
||||
|
||||
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
|
||||
// using the SSH protocol.
|
||||
func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.Client, error) {
|
||||
func CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {
|
||||
var port string
|
||||
if strings.Contains(address, ":") {
|
||||
addressAndPort := strings.Split(address, ":")
|
||||
@@ -258,13 +260,25 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
|
||||
} else {
|
||||
port = "22"
|
||||
}
|
||||
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
|
||||
|
||||
// Build auth methods: prefer parsed private key if provided, fall back to password.
|
||||
var authMethods []ssh.AuthMethod
|
||||
if len(privateKey) > 0 {
|
||||
if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
} else {
|
||||
return false, nil, fmt.Errorf("invalid private key: %w", err)
|
||||
}
|
||||
}
|
||||
if len(password) > 0 {
|
||||
authMethods = append(authMethods, ssh.Password(password))
|
||||
}
|
||||
|
||||
cli, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
Timeout: config.Timeout,
|
||||
Auth: authMethods,
|
||||
Timeout: config.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
@@ -301,7 +315,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 +323,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 +356,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 +372,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 (
|
||||
|
||||
+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)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+50
-53
@@ -300,61 +300,61 @@ 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 := validateTunnelingConfig(config); err != nil {
|
||||
if err := ValidateTunnelingConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAnnouncementsConfig(config); err != nil {
|
||||
if err := ValidateAnnouncementsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSuitesConfig(config); err != nil {
|
||||
if err := ValidateSuitesConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateUniqueKeys(config); err != nil {
|
||||
if err := ValidateUniqueKeys(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validateAndSetConcurrencyDefaults(config)
|
||||
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
|
||||
}
|
||||
|
||||
// validateTunnelingConfig validates the tunneling configuration and resolves tunnel references
|
||||
// NOTE: This must be called after validateEndpointsConfig and validateSuitesConfig
|
||||
// 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 {
|
||||
func ValidateTunnelingConfig(config *Config) error {
|
||||
if config.Tunneling != nil {
|
||||
if err := config.Tunneling.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
@@ -404,7 +404,7 @@ func resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnnouncementsConfig(config *Config) error {
|
||||
func ValidateAnnouncementsConfig(config *Config) error {
|
||||
if config.Announcements != nil {
|
||||
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
|
||||
return err
|
||||
@@ -415,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
|
||||
@@ -424,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,
|
||||
@@ -439,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 {
|
||||
@@ -450,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 {
|
||||
@@ -461,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 {
|
||||
@@ -470,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 {
|
||||
@@ -484,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 {
|
||||
@@ -497,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)
|
||||
@@ -532,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 {
|
||||
@@ -573,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.ValidateAndSetDefaults() {
|
||||
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{
|
||||
@@ -610,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,
|
||||
@@ -647,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,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)
|
||||
}
|
||||
@@ -683,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)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-68
@@ -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"
|
||||
@@ -775,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
|
||||
@@ -801,7 +796,6 @@ endpoints:
|
||||
success-threshold: 15
|
||||
- type: teams
|
||||
- type: pushover
|
||||
- type: jetbrainsspace
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
@@ -828,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 {
|
||||
@@ -936,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) {
|
||||
@@ -1001,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"
|
||||
@@ -1047,7 +1027,6 @@ endpoints:
|
||||
- type: twilio
|
||||
- type: teams
|
||||
- type: pushover
|
||||
- type: jetbrainsspace
|
||||
- type: email
|
||||
- type: gotify
|
||||
conditions:
|
||||
@@ -1169,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")
|
||||
}
|
||||
@@ -1256,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 {
|
||||
@@ -1374,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")
|
||||
@@ -1399,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) {
|
||||
@@ -1917,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{},
|
||||
@@ -1962,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},
|
||||
@@ -2596,19 +2544,19 @@ func TestValidateTunnelingConfig(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateTunnelingConfig(tt.config)
|
||||
err := ValidateTunnelingConfig(tt.config)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("validateTunnelingConfig() expected error but got none")
|
||||
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)
|
||||
t.Errorf("ValidateTunnelingConfig() error = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("validateTunnelingConfig() unexpected error = %v", err)
|
||||
t.Errorf("ValidateTunnelingConfig() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,7 +209,44 @@ 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"
|
||||
s = strings.TrimSuffix(s, "0m")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
||||
|
||||
@@ -476,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",
|
||||
@@ -490,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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -497,8 +503,8 @@ 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 there's no username, password or private key specified, attempt to validate just the SSH banner
|
||||
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
|
||||
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
@@ -509,17 +515,35 @@ func (e *Endpoint) call(result *Result) {
|
||||
return
|
||||
}
|
||||
var cli *ssh.Client
|
||||
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
|
||||
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
@@ -511,26 +511,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
privateKey string
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "fail when has no user",
|
||||
name: "fail when has no user but has password",
|
||||
username: "",
|
||||
password: "password",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
||||
},
|
||||
{
|
||||
name: "fail when has no password",
|
||||
username: "username",
|
||||
password: "",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
|
||||
name: "fail when has no user but has private key",
|
||||
username: "",
|
||||
privateKey: "-----BEGIN",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
||||
},
|
||||
{
|
||||
name: "success when all fields are set",
|
||||
name: "fail when has no password or private key",
|
||||
username: "username",
|
||||
password: "",
|
||||
privateKey: "",
|
||||
expectedErr: ssh.ErrEndpointWithoutSSHAuth,
|
||||
},
|
||||
{
|
||||
name: "success when username and password are set",
|
||||
username: "username",
|
||||
password: "password",
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "success when username and private key are set",
|
||||
username: "username",
|
||||
privateKey: "-----BEGIN",
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -539,8 +553,9 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
||||
Name: "ssh-test",
|
||||
URL: "https://example.com",
|
||||
SSHConfig: &ssh.Config{
|
||||
Username: scenario.username,
|
||||
Password: scenario.password,
|
||||
Username: scenario.username,
|
||||
Password: scenario.password,
|
||||
PrivateKey: scenario.privateKey,
|
||||
},
|
||||
Conditions: []Condition{Condition("[STATUS] == 0")},
|
||||
}
|
||||
@@ -1448,3 +1463,168 @@ func TestEndpoint_preprocessWithContext(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_HideUIFeatures(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint Endpoint
|
||||
mockResponse test.MockRoundTripper
|
||||
checkHostname bool
|
||||
expectHostname string
|
||||
checkErrors bool
|
||||
expectErrors bool
|
||||
checkConditions bool
|
||||
expectConditions bool
|
||||
checkErrorContent string
|
||||
}{
|
||||
{
|
||||
name: "hide-conditions",
|
||||
endpoint: Endpoint{
|
||||
Name: "test-endpoint",
|
||||
URL: "https://example.com/health",
|
||||
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
|
||||
UIConfig: &ui.Config{HideConditions: true},
|
||||
},
|
||||
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`))}
|
||||
}),
|
||||
checkConditions: true,
|
||||
expectConditions: false,
|
||||
},
|
||||
{
|
||||
name: "hide-hostname",
|
||||
endpoint: Endpoint{
|
||||
Name: "test-endpoint",
|
||||
URL: "https://example.com/health",
|
||||
Conditions: []Condition{"[STATUS] == 200"},
|
||||
UIConfig: &ui.Config{HideHostname: true},
|
||||
},
|
||||
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
checkHostname: true,
|
||||
expectHostname: "",
|
||||
},
|
||||
{
|
||||
name: "hide-url-in-errors",
|
||||
endpoint: Endpoint{
|
||||
Name: "test-endpoint",
|
||||
URL: "https://example.com/health",
|
||||
Conditions: []Condition{"[CONNECTED] == true"},
|
||||
UIConfig: &ui.Config{HideURL: true},
|
||||
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||
},
|
||||
mockResponse: nil,
|
||||
checkErrors: true,
|
||||
expectErrors: true,
|
||||
checkErrorContent: "<redacted>",
|
||||
},
|
||||
{
|
||||
name: "hide-port-in-errors",
|
||||
endpoint: Endpoint{
|
||||
Name: "test-endpoint",
|
||||
URL: "https://example.com:9999/health",
|
||||
Conditions: []Condition{"[CONNECTED] == true"},
|
||||
UIConfig: &ui.Config{HidePort: true},
|
||||
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||
},
|
||||
mockResponse: nil,
|
||||
checkErrors: true,
|
||||
expectErrors: true,
|
||||
checkErrorContent: "<redacted>",
|
||||
},
|
||||
{
|
||||
name: "hide-errors",
|
||||
endpoint: Endpoint{
|
||||
Name: "test-endpoint",
|
||||
URL: "https://example.com/health",
|
||||
Conditions: []Condition{"[CONNECTED] == true"},
|
||||
UIConfig: &ui.Config{HideErrors: true},
|
||||
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||
},
|
||||
mockResponse: nil,
|
||||
checkErrors: true,
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "dont-resolve-failed-conditions",
|
||||
endpoint: Endpoint{
|
||||
Name: "test-endpoint",
|
||||
URL: "https://example.com/health",
|
||||
Conditions: []Condition{"[STATUS] == 200"},
|
||||
UIConfig: &ui.Config{DontResolveFailedConditions: true},
|
||||
},
|
||||
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
|
||||
}),
|
||||
checkConditions: true,
|
||||
expectConditions: true,
|
||||
},
|
||||
{
|
||||
name: "multiple-hide-features",
|
||||
endpoint: Endpoint{
|
||||
Name: "test-endpoint",
|
||||
URL: "https://example.com/health",
|
||||
Conditions: []Condition{"[STATUS] == 200"},
|
||||
UIConfig: &ui.Config{HideConditions: true, HideHostname: true, HideErrors: true},
|
||||
},
|
||||
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
checkConditions: true,
|
||||
expectConditions: false,
|
||||
checkHostname: true,
|
||||
expectHostname: "",
|
||||
checkErrors: true,
|
||||
expectErrors: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.mockResponse != nil {
|
||||
mockClient := &http.Client{Transport: tt.mockResponse}
|
||||
if tt.endpoint.ClientConfig != nil && tt.endpoint.ClientConfig.Timeout > 0 {
|
||||
mockClient.Timeout = tt.endpoint.ClientConfig.Timeout
|
||||
}
|
||||
client.InjectHTTPClient(mockClient)
|
||||
} else {
|
||||
client.InjectHTTPClient(nil)
|
||||
}
|
||||
err := tt.endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
|
||||
}
|
||||
result := tt.endpoint.EvaluateHealth()
|
||||
if tt.checkHostname {
|
||||
if result.Hostname != tt.expectHostname {
|
||||
t.Errorf("Expected hostname '%s', got '%s'", tt.expectHostname, result.Hostname)
|
||||
}
|
||||
}
|
||||
if tt.checkErrors {
|
||||
hasErrors := len(result.Errors) > 0
|
||||
if hasErrors != tt.expectErrors {
|
||||
t.Errorf("Expected errors=%v, got errors=%v (actual errors: %v)", tt.expectErrors, hasErrors, result.Errors)
|
||||
}
|
||||
if tt.checkErrorContent != "" && len(result.Errors) > 0 {
|
||||
found := false
|
||||
for _, err := range result.Errors {
|
||||
if strings.Contains(err, tt.checkErrorContent) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected error to contain '%s', but got: %v", tt.checkErrorContent, result.Errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
if tt.checkConditions {
|
||||
hasConditions := len(result.ConditionResults) > 0
|
||||
if hasConditions != tt.expectConditions {
|
||||
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,26 +8,29 @@ var (
|
||||
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
|
||||
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
|
||||
|
||||
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
|
||||
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
|
||||
// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
|
||||
ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Username string `yaml:"username,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
Username string `yaml:"username,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
PrivateKey string `yaml:"private-key,omitempty"`
|
||||
}
|
||||
|
||||
// Validate the SSH configuration
|
||||
func (cfg *Config) Validate() error {
|
||||
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
|
||||
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
|
||||
// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid
|
||||
if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
|
||||
return nil
|
||||
}
|
||||
// If any authentication method is provided (password or private key), a username is required
|
||||
if len(cfg.Username) == 0 {
|
||||
return ErrEndpointWithoutSSHUsername
|
||||
}
|
||||
if len(cfg.Password) == 0 {
|
||||
return ErrEndpointWithoutSSHPassword
|
||||
// If a username is provided, require at least a password or a private key
|
||||
if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
|
||||
return ErrEndpointWithoutSSHAuth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSSH_validate(t *testing.T) {
|
||||
func TestSSH_validatePasswordCfg(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Error("didn't expect an error")
|
||||
@@ -13,11 +13,26 @@ func TestSSH_validate(t *testing.T) {
|
||||
cfg.Username = "username"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("expected an error")
|
||||
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
|
||||
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
|
||||
} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
|
||||
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
|
||||
}
|
||||
cfg.Password = "password"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("expected no error, got '%v'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSH_validatePrivateKeyCfg(t *testing.T) {
|
||||
t.Run("fail when username missing but private key provided", func(t *testing.T) {
|
||||
cfg := &Config{PrivateKey: "-----BEGIN"}
|
||||
if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {
|
||||
t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("success when username with private key", func(t *testing.T) {
|
||||
cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,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()) {
|
||||
|
||||
@@ -54,7 +54,6 @@ 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 != "" {
|
||||
@@ -66,7 +65,6 @@ func New(config *Config) *SSHTunnel {
|
||||
} else if config.Password != "" {
|
||||
tunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}
|
||||
}
|
||||
|
||||
return tunnel
|
||||
}
|
||||
|
||||
@@ -131,27 +129,34 @@ func (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {
|
||||
client = t.client
|
||||
t.mu.Unlock()
|
||||
}
|
||||
// Create connection through SSH tunnel
|
||||
conn, err := client.Dial(network, addr)
|
||||
if err != nil {
|
||||
// Close stale connection before retry to prevent leak
|
||||
t.mu.Lock()
|
||||
if t.client != nil {
|
||||
t.client.Close()
|
||||
t.client = nil
|
||||
// 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()
|
||||
}
|
||||
t.mu.Unlock()
|
||||
// Retry once - connection might be stale
|
||||
if connErr := t.Connect(); connErr != nil {
|
||||
return nil, fmt.Errorf("SSH tunnel dial failed: %w (retry failed: %v)", err, connErr)
|
||||
}
|
||||
t.mu.RLock()
|
||||
client = t.client
|
||||
t.mu.RUnlock()
|
||||
conn, err = client.Dial(network, addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH tunnel dial failed after retry: %w", err)
|
||||
conn, err := client.Dial(network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return conn, nil
|
||||
return nil, fmt.Errorf("SSH tunnel dial failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ 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:"-"`
|
||||
mu sync.RWMutex `yaml:"-"`
|
||||
connections map[string]*sshtunnel.SSHTunnel `yaml:"-"`
|
||||
}
|
||||
|
||||
|
||||
@@ -188,4 +188,4 @@ func TestConfig_Close(t *testing.T) {
|
||||
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,72 @@ 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.40.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.2
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7
|
||||
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.17.0
|
||||
google.golang.org/api v0.242.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/oauth2 v0.32.0
|
||||
golang.org/x/sync v0.18.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.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect
|
||||
github.com/aws/smithy-go v1.23.2 // 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 +79,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.29.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.38.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,48 @@ 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.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7 h1:As+tSgow5R48+hFwkgtEBOFYFtvSEqMal2QSMRccWgw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.7/go.mod h1:DgSdE3UxodNau1977aGbt4YixHYESTNAEStFo9qJ2TQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
|
||||
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
|
||||
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
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 +70,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 +99,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 +132,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 +149,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 +166,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 +187,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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
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 +198,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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
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 +209,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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.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 +234,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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.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 +244,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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
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 +255,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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
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 +286,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 +308,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=
|
||||
|
||||
@@ -183,6 +183,33 @@ func initializeStorage(cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load persisted triggered alerts for suite endpoints
|
||||
for _, suite := range cfg.Suites {
|
||||
for _, ep := range suite.Endpoints {
|
||||
var checksums []string
|
||||
for _, alert := range ep.Alerts {
|
||||
if alert.IsEnabled() {
|
||||
checksums = append(checksums, alert.Checksum())
|
||||
}
|
||||
}
|
||||
numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums)
|
||||
if numberOfTriggeredAlertsDeleted > 0 {
|
||||
logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for suite endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key())
|
||||
}
|
||||
for _, alert := range ep.Alerts {
|
||||
exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert)
|
||||
if err != nil {
|
||||
logr.Errorf("[main.initializeStorage] Failed to get triggered alert for suite endpoint with key=%s: %s", ep.Key(), err.Error())
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
alert.Triggered, alert.ResolveKey = true, resolveKey
|
||||
ep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold
|
||||
numberOfPersistedTriggeredAlertsLoaded++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if numberOfPersistedTriggeredAlertsLoaded > 0 {
|
||||
logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
+42
-8
@@ -133,7 +133,7 @@
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="`${SERVER_URL}/oidc/login`"
|
||||
:href="`/oidc/login`"
|
||||
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full"
|
||||
@click="isOidcLoading = true"
|
||||
>
|
||||
@@ -148,7 +148,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<Tooltip :result="tooltip.result" :event="tooltip.event" />
|
||||
<Tooltip :result="tooltip.result" :event="tooltip.event" :isPersistent="tooltipIsPersistent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -162,7 +162,6 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import Social from './components/Social.vue'
|
||||
import Tooltip from './components/Tooltip.vue'
|
||||
import Loading from './components/Loading.vue'
|
||||
import { SERVER_URL } from '@/main'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -173,6 +172,7 @@ const announcements = ref([])
|
||||
const tooltip = ref({})
|
||||
const mobileMenuOpen = ref(false)
|
||||
const isOidcLoading = ref(false)
|
||||
const tooltipIsPersistent = ref(false)
|
||||
let configInterval = null
|
||||
|
||||
// Computed properties
|
||||
@@ -195,22 +195,52 @@ const buttons = computed(() => {
|
||||
// Methods
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
|
||||
retrievedConfig.value = true
|
||||
|
||||
const response = await fetch(`/api/v1/config`, { credentials: 'include' })
|
||||
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 +248,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 +258,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
|
||||
@@ -145,7 +154,8 @@ const formattedResponseTime = computed(() => {
|
||||
|
||||
const oldestResultTime = computed(() => {
|
||||
if (!props.endpoint.results || props.endpoint.results.length === 0) return ''
|
||||
return generatePrettyTimeAgo(props.endpoint.results[0].timestamp)
|
||||
const oldestResultIndex = Math.max(0, props.endpoint.results.length - props.maxResults)
|
||||
return generatePrettyTimeAgo(props.endpoint.results[oldestResultIndex].timestamp)
|
||||
})
|
||||
|
||||
const newestResultTime = computed(() => {
|
||||
@@ -156,4 +166,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>
|
||||
@@ -64,9 +64,10 @@ const sortOptions = [
|
||||
|
||||
const emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])
|
||||
|
||||
const handleFilterChange = (value) => {
|
||||
const handleFilterChange = (value, store = true) => {
|
||||
filterBy.value = value
|
||||
localStorage.setItem('gatus:filter-by', value)
|
||||
if (store)
|
||||
localStorage.setItem('gatus:filter-by', value)
|
||||
|
||||
// Reset all filter states first
|
||||
emit('update:showOnlyFailing', false)
|
||||
@@ -80,9 +81,11 @@ const handleFilterChange = (value) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSortChange = (value) => {
|
||||
const handleSortChange = (value, store = true) => {
|
||||
sortBy.value = value
|
||||
localStorage.setItem('gatus:sort-by', value)
|
||||
if (store)
|
||||
localStorage.setItem('gatus:sort-by', value)
|
||||
|
||||
emit('update:sortBy', value)
|
||||
emit('update:groupByGroup', value === 'group')
|
||||
|
||||
@@ -93,8 +96,8 @@ const handleSortChange = (value) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Apply saved filter/sort state on load
|
||||
handleFilterChange(filterBy.value)
|
||||
handleSortChange(sortBy.value)
|
||||
// Apply saved or application wide filter/sort state on load but do not store it in localstorage
|
||||
handleFilterChange(filterBy.value, false)
|
||||
handleSortChange(sortBy.value, false)
|
||||
})
|
||||
</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: {
|
||||
|
||||
@@ -3,6 +3,4 @@ import App from './App.vue'
|
||||
import './index.css'
|
||||
import router from './router'
|
||||
|
||||
export const SERVER_URL = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:8080'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<CardTitle class="text-sm font-medium text-muted-foreground">Avg Response Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}ms</div>
|
||||
<div class="text-2xl font-bold">{{ pageAverageResponseTime }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -89,13 +89,13 @@
|
||||
<EndpointCard
|
||||
v-if="endpointStatus"
|
||||
:endpoint="endpointStatus"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
class="border-0 shadow-none bg-transparent p-0"
|
||||
/>
|
||||
<div v-if="endpointStatus && endpointStatus.key" class="pt-4 border-t">
|
||||
<Pagination @page="changePage" :numberOfResultsPerPage="50" :currentPageProp="currentPage" />
|
||||
<Pagination @page="changePage" :numberOfResultsPerPage="resultPageSize" :currentPageProp="currentPage" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -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,7 +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 { SERVER_URL } from '@/main.js'
|
||||
import ResponseTimeChart from '@/components/ResponseTimeChart.vue'
|
||||
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -217,10 +223,10 @@ const endpointStatus = ref(null) // For paginated historical data
|
||||
const currentStatus = ref(null) // For current/latest status (always page 1)
|
||||
const events = ref([])
|
||||
const currentPage = ref(1)
|
||||
const resultPageSize = 50
|
||||
const showResponseTimeChartAndBadges = ref(false)
|
||||
const showAverageResponseTime = ref(false)
|
||||
const selectedChartDuration = ref('24h')
|
||||
const serverUrl = SERVER_URL === '.' ? '..' : SERVER_URL
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
const latestResult = computed(() => {
|
||||
@@ -254,7 +260,7 @@ const pageAverageResponseTime = computed(() => {
|
||||
}
|
||||
}
|
||||
if (count === 0) return 'N/A'
|
||||
return Math.round(total / count / 1000000)
|
||||
return `${Math.round(total / count / 1000000)}ms`
|
||||
})
|
||||
|
||||
const pageResponseTimeRange = computed(() => {
|
||||
@@ -267,17 +273,17 @@ const pageResponseTimeRange = computed(() => {
|
||||
let hasData = false
|
||||
|
||||
for (const result of endpointStatus.value.results) {
|
||||
if (result.duration) {
|
||||
const durationMs = result.duration / 1000000
|
||||
min = Math.min(min, durationMs)
|
||||
max = Math.max(max, durationMs)
|
||||
const duration = result.duration
|
||||
if (duration) {
|
||||
min = Math.min(min, duration)
|
||||
max = Math.max(max, duration)
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasData) return 'N/A'
|
||||
const minMs = Math.round(min)
|
||||
const maxMs = Math.round(max)
|
||||
const minMs = Math.round(min / 1000000)
|
||||
const maxMs = Math.round(max / 1000000)
|
||||
// If min and max are the same, show single value
|
||||
if (minMs === maxMs) {
|
||||
return `${minMs}ms`
|
||||
@@ -297,7 +303,7 @@ const lastCheckTime = computed(() => {
|
||||
const fetchData = async () => {
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=50`, {
|
||||
const response = await fetch(`/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=${resultPageSize}`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
@@ -369,8 +375,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) => {
|
||||
@@ -378,19 +384,15 @@ const prettifyTimestamp = (timestamp) => {
|
||||
}
|
||||
|
||||
const generateHealthBadgeImageURL = () => {
|
||||
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
|
||||
return `/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`
|
||||
}
|
||||
|
||||
const generateUptimeBadgeImageURL = (duration) => {
|
||||
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
|
||||
return `/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`
|
||||
}
|
||||
|
||||
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`
|
||||
return `/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
+36
-13
@@ -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"
|
||||
@@ -82,7 +82,7 @@
|
||||
v-for="suite in items.suites"
|
||||
:key="suite.key"
|
||||
:suite="suite"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
@@ -96,7 +96,7 @@
|
||||
v-for="endpoint in items.endpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
@@ -116,7 +116,7 @@
|
||||
v-for="suite in paginatedSuites"
|
||||
:key="suite.key"
|
||||
:suite="suite"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@
|
||||
v-for="endpoint in paginatedEndpoints"
|
||||
:key="endpoint.key"
|
||||
:endpoint="endpoint"
|
||||
:maxResults="50"
|
||||
:maxResults="resultPageSize"
|
||||
:showAverageResponseTime="showAverageResponseTime"
|
||||
@showTooltip="showTooltip"
|
||||
/>
|
||||
@@ -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,7 +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 { SERVER_URL } from '@/main.js'
|
||||
import PastAnnouncements from '@/components/PastAnnouncements.vue'
|
||||
|
||||
const props = defineProps({
|
||||
announcements: {
|
||||
@@ -196,6 +201,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([])
|
||||
@@ -210,6 +224,7 @@ const showAverageResponseTime = ref(true)
|
||||
const groupByGroup = ref(false)
|
||||
const sortBy = ref(localStorage.getItem('gatus:sort-by') || 'name')
|
||||
const uncollapsedGroups = ref(new Set())
|
||||
const resultPageSize = 50
|
||||
|
||||
const filteredEndpoints = computed(() => {
|
||||
let filtered = [...endpointStatuses.value]
|
||||
@@ -418,7 +433,7 @@ const fetchData = async () => {
|
||||
}
|
||||
try {
|
||||
// Fetch endpoints
|
||||
const endpointResponse = await fetch(`${SERVER_URL}/api/v1/endpoints/statuses?page=1&pageSize=100`, {
|
||||
const endpointResponse = await fetch(`/api/v1/endpoints/statuses?page=1&pageSize=${resultPageSize}`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (endpointResponse.status === 200) {
|
||||
@@ -429,7 +444,7 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
// Fetch suites
|
||||
const suiteResponse = await fetch(`${SERVER_URL}/api/v1/suites/statuses?page=1&pageSize=100`, {
|
||||
const suiteResponse = await fetch(`/api/v1/suites/statuses?page=1&pageSize=${resultPageSize}`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (suiteResponse.status === 200) {
|
||||
@@ -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" />
|
||||
@@ -154,7 +154,6 @@ import StepDetailsModal from '@/components/StepDetailsModal.vue'
|
||||
import Settings from '@/components/Settings.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { generatePrettyTimeAgo } from '@/utils/time'
|
||||
import { SERVER_URL } from '@/main'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -184,20 +183,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`, {
|
||||
const response = await fetch(`/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 +216,9 @@ const fetchData = async () => {
|
||||
} catch (error) {
|
||||
console.error('[SuiteDetails][fetchData] Error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (isInitialLoad) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-2
@@ -6,5 +6,19 @@ module.exports = {
|
||||
filenameHashing: false,
|
||||
productionSourceMap: false,
|
||||
outputDir: '../static',
|
||||
publicPath: '/'
|
||||
}
|
||||
publicPath: '/',
|
||||
devServer: {
|
||||
port: 8081,
|
||||
https: false,
|
||||
client: {
|
||||
webSocketURL:'auto://0.0.0.0/ws'
|
||||
},
|
||||
proxy: {
|
||||
'^/api|^/css|^/oicd': {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
secure: 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