Sync with upstream

This commit is contained in:
Philipp Kolmann 2024-10-18 15:41:27 +02:00
commit 6bd7d272a1
138 changed files with 4868 additions and 1559 deletions

View File

@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFBjCCAu6gAwIBAgIUHJXHAqywj2v25AgX7pDSZ+LX4iAwDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTQ1MDFaFw0yOTA0MjQw
MTQ1MDFaMBExDzANBgNVBAMMBmNsaWVudDCCAiIwDQYJKoZIhvcNAQEBBQADggIP
ADCCAgoCggIBANTmRlS5BNG82mOdrhtRPIBD5U40nEW4CVFm85ZJ4Bge4Ty86juf
aoCnI6AEfwpVnJhXPzjUsMBxJFMbiCB+QTJRpxTphtK7orpbwRHjaDZNaLr1MrUO
ieADGiHw93zVDikD8FP5vG+2XWWA56hY84Ac0TR9GqPjsW0nobMgBNgsRtbYUD0B
T5QOItK180xQRn4jbys5jRnr161S+Sbg6mglz1LBFBCLmZnhZFZ8FAn87gumbnWN
etSnu9kX6iOXBIaB+3nuHOL4xmAan8tAyen6mPfkXrE5ogovjqFFMTUJOKQoJVp3
zzm/0XYANxoItFGtdjGMTl5IgI220/6kfpn6PYN7y1kYn5EI+UbobD/CuAhd94p6
aQwOXU53/l+eNH/XnTsL/32QQ6qdq8sYqevlslk1M39kKNewWYCeRzYlCVscQk14
O3fkyXrtRkz30xrzfjvJQ/VzMi+e5UlemsCuCXTVZ5YyBnuWyY+mI6lZICltZSSX
VinKzpz+t4Jl7glhKiGHaNAkBX2oLddyf280zw4Cx7nDMPs4uOHONYpm90IxEOJe
zgJ9YxPK9aaKv2AoYLbvhYyKrVT+TFqoEsbQk4vK0t0Gc1j5z4dET31CSOuxVnnU
LYwtbILFc0uZrbuOAbEbXtjPpw2OGqWagD0QpkE8TjN0Hd0ibyXyUuz5AgMBAAGj
VTBTMBEGA1UdEQQKMAiCBmNsaWVudDAdBgNVHQ4EFgQUleILTHG5lT2RhSe9H4fV
xUh0bNUwHwYDVR0jBBgwFoAUbh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcN
AQELBQADggIBABq8zjRrDaljl867MXAlmbV7eJkSnaWRFct+N//jCVNnKMYaxyQm
+UG12xYP0U9Zr9vhsqwyTZTQFx/ZFiiz2zfXPtUAppV3AjE67IlKRbec3qmUhj0H
Rv20eNNWXTl1XTX5WDV5887TF+HLZm/4W2ZSBbS3V89cFhBLosy7HnBGrP0hACne
ZbdQWnnLHJMDKXkZey1H1ZLQQCQdAKGS147firj29M8uzSRHgrR6pvsNQnRT0zDL
TlTJoxyGTMaoj+1IZvRsAYMZCRb8Yct/v2i/ukIykFWUJZ+1Z3UZhGrX+gdhLfZM
jAP4VQ+vFgwD6NEXAA2DatoRqxbN1ZGJQkvnobWJdZDiYu4hBCs8ugKUTE+0iXWt
hSyrAVUspFCIeDN4xsXT5b0j2Ps4bpSAiGx+aDDTPUnd881I6JGCiIavgvdFMLCW
yOXJOZvXcNQwsndkob5fZAEqetjrARsHhQuygEq/LnPc6lWsO8O6UzYArEiKWTMx
N/5hx12Pb7aaQd1f4P3gmmHMb/YiCQK1Qy5d4v68POeqyrLvAHbvCwEMhBAbnLvw
gne3psql8s5wxhnzwYltcBUmmAw1t33CwzRBGEKifRdLGtA9pbua4G/tomcDDjVS
ChsHGebJvNxOnsQqoGgozqM2x8ScxmJzIflGxrKmEA8ybHpU0d02Xp3b
-----END CERTIFICATE-----

View File

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEA1OZGVLkE0bzaY52uG1E8gEPlTjScRbgJUWbzlkngGB7hPLzq
O59qgKcjoAR/ClWcmFc/ONSwwHEkUxuIIH5BMlGnFOmG0ruiulvBEeNoNk1ouvUy
tQ6J4AMaIfD3fNUOKQPwU/m8b7ZdZYDnqFjzgBzRNH0ao+OxbSehsyAE2CxG1thQ
PQFPlA4i0rXzTFBGfiNvKzmNGevXrVL5JuDqaCXPUsEUEIuZmeFkVnwUCfzuC6Zu
dY161Ke72RfqI5cEhoH7ee4c4vjGYBqfy0DJ6fqY9+ResTmiCi+OoUUxNQk4pCgl
WnfPOb/RdgA3Ggi0Ua12MYxOXkiAjbbT/qR+mfo9g3vLWRifkQj5RuhsP8K4CF33
inppDA5dTnf+X540f9edOwv/fZBDqp2ryxip6+WyWTUzf2Qo17BZgJ5HNiUJWxxC
TXg7d+TJeu1GTPfTGvN+O8lD9XMyL57lSV6awK4JdNVnljIGe5bJj6YjqVkgKW1l
JJdWKcrOnP63gmXuCWEqIYdo0CQFfagt13J/bzTPDgLHucMw+zi44c41imb3QjEQ
4l7OAn1jE8r1poq/YChgtu+FjIqtVP5MWqgSxtCTi8rS3QZzWPnPh0RPfUJI67FW
edQtjC1sgsVzS5mtu44BsRte2M+nDY4apZqAPRCmQTxOM3Qd3SJvJfJS7PkCAwEA
AQKCAgAPwAALUStib3aMkLlfpfve1VGyc8FChcySrBYbKS3zOt2Y27T3DOJuesRE
7fA5Yyn+5H1129jo87XR5s3ZnDLV4SUw2THd3H8RCwFWgcdPinHUBZhnEpial5V9
q1DzzY3gSj1OSRcVVfLE3pYaEIflvhFasQ1L0JLAq4I9OSzX5+FPEEOnWmB5Ey6k
/fbuJLDXsLwPAOadDfiFBwgNm0KxdRKdtvugBGPW9s4Fzo9rnxLmjmfKOdmQv96Y
FI/Vat0Cgmfd661RZpbDvKnTpIsLdzw3zTpAIYOzqImvCT+3AmP2qPhSdV3sPMeR
047qqyLZOVxEFXLQFiGvL4uxYUPy8k0ZI9xkgOfZ/uASozMWsHkaD04+UDi1+kw5
nfasZLvOWBW/WE/E1Rfz8IiYTeZbgTnY4CraiLrIRc0LGgD1Df4gNr25+P+LKLyK
/WW89dl6/397HOFnA7CHi7DaA8+9uZAjOWhoCNDdqAVa3QpDD/3/iRiih26bjJfH
2+sarxU8GovDZFxWd59BUP3jkukCFH+CliQy72JtLXiuPNPAWeGV9UXxtIu40sRX
Sax/TQytYi2J9NJFZFMTwVueIfzsWc8dyM+IPAYJQxN94xYKQU4+Rb/wqqHgUfjT
1ZQJb8Cmg56IDY/0EPJWQ0qgnE7TZbY2BOEYbpOzdccwUbcEjQKCAQEA8kVyw4Hw
nqcDWXjzMhOOoRoF8CNwXBvE2KBzpuAioivGcSkjkm8vLGfQYAbDOVMPFt3xlZS0
0lQm894176Kk8BiMqtyPRWWOsv4vYMBTqbehKn09Kbh6lM7d7jO7sh5iWf4jt3Bw
Sk4XhZ9oQ/kpnEKiHPymHQY3pVYEyFCGJ8mdS6g/TWiYmjMjkQDVFA4xkiyJ0S5J
NGYxI+YXtHVTVNSePKvY0h51EqTxsexAphGjXnQ3xoe6e3tVGBkeEkcZlESFD/91
0iqdc5VtKQOwy6Tj4Awk7oK5/u3tfpyIyo31LQIqreTqMO534838lpyp3CbRdvCF
QdCNpKFX1gZgmwKCAQEA4Pa9VKO3Aw95fpp0T81xNi+Js/NhdsvQyv9NI9xOKKQU
hiWxmYmyyna3zliDGlqtlw113JFTNQYl1k1yi4JQPu2gnj8te9nB0yv0RVxvbTOq
u8K1j9Xmj8XVpcKftusQsZ2xu52ONj3ZOOf22wE4Y6mdQcps+rN6XTHRBn7a5b0v
ZCvWf4CIttdIh51pZUIbZKHTU51uU7AhTCY/wEUtiHwYTT9Wiy9Lmay5Lh2s2PCz
yPE5Y970nOzlSCUl3bVgY1t0xbQtaO5AJ/iuw/vNw+YAiAIPNDUcbcK5njb//+0E
uTEtDA6SHeYfsNXGDzxipueKXFHfJLCTXnnT5/1v+wKCAQEA0pF78uNAQJSGe8B9
F3waDnmwyYvzv4q/J00l19edIniLrJUF/uM2DBFa8etOyMchKU3UCJ9MHjbX+EOd
e19QngGoWWUD/VwMkBQPF7dxv+QDZwudGmLl3+qAx+Uc8O4pq3AQmQJYBq0jEpd/
Jv0rpk3f2vPYaQebW8+MrpIWWASK+1QLWPtdD0D9W61uhVTkzth5HF9vbuSXN01o
Mwd6WxPFSJRQCihAtui3zV26vtw7sv+t7pbPhT2nsx85nMdBOzXmtQXi4Lz7RpeM
XgaAJi91g6jqfIcQo7smHVJuLib9/pWQhL2estLBTzUcocced2Mh0Y+xMofSZFF7
J2E5mwKCAQAO9npbUdRPYM0c7ZsE385C42COVobKBv5pMhfoZbPRIjC3R3SLmMwK
iWDqWZrGuvdGz79iH0xgf3suyNHwk4dQ2C9RtzQIQ9CPgiHqJx7GLaSSfn3jBkAi
me7+6nYDDZl7pth2eSFHXE/BaDRUFr2wa0ypXpRnDF78Kd8URoW6uB2Z1QycSGlP
d/w8AO1Mrdvykozix9rZuCJO1VByMme350EaijbwZQHrQ8DBX3nqp//dQqYljWPJ
uDv703S0TWcO1LtslvJaQ1aDEhhVsr7Z48dvRGvMdifg6Q29hzz5wcMJqkqrvaBc
Wr0K3v0gcEzDey0JvOxRnWj/5KyChqnXAoIBAQDq6Dsks6BjVP4Y1HaA/NWcZxUU
EZfNCTA19jIHSUiPbWzWHNdndrUq33HkPorNmFaEIrTqd/viqahr2nXpYiY/7E+V
cpn9eSxot5J8DB4VI92UG9kixxY4K7QTMKvV43Rt6BLosW/cHxW5XTNhB4JDK+TO
NlHH48fUp2qJh7/qwSikDG130RVHKwK/5Fv3NQyXTw1/n9bhnaC4eSvV39CNSeb5
rWNEZcnc9zHT2z1UespzVTxVy4hscrkssXxcCq4bOF4bnDFjfblE43o/KrVr2/Ub
jzpXQrAwXNq7pAkIpin0v40lCeTMosSgQLFqMWmtmlCpBVkyEAc9ZYXc3Vs0
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIE9DCCAtygAwIBAgIUCXgA3IbeA2mn8DQ0E5IxaKBLtf8wDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTE5MzRaFw0zNDA0MjMw
MTE5MzRaMBIxEDAOBgNVBAMMB2V4YW1wbGUwggIiMA0GCSqGSIb3DQEBAQUAA4IC
DwAwggIKAoICAQDLE4aTrVJrAVYksFJt5fIVhEJT5T0cLqvtDRf9hXA5Gowremsl
VJPBm4qbdImzJZCfCcbVjFEBw8h9xID1JUqRWjJ8BfTnpa4qc1e+xRtnvC+OsUeT
CCgZvK3TZ5vFsaEbRoNGuiaNq9WSTfjLwTxkK6C3Xogm9uDx73PdRob1TNK5A9mE
Ws3ZyV91+g1phKdlNMRaK+wUrjUjEMLgr0t5A5t6WKefsGrFUDaT3sye3ZxDYuEa
ljt+F8hLVyvkDBAhh6B4S5dQILjp7L3VgOsG7Hx9py1TwCbpWXZEuee/1/2OD8tA
ALsxkvRE1w4AZzLPYRL/dOMllLjROQ4VugU8GVpNU7saK5SeWBw3XHyJ9m8vne3R
cPWaZTfkwfj8NjCgi9BzBPW8/uw7XZMmQFyTj494OKM3T5JQ5jZ5XD97ONm9h+C/
oOmkcWHz6IwEUu7XV5IESxiFlrq8ByAYF98XPhn2wMMrm2OvHMOwrfw2+5U8je5C
z70p9kpiGK8qCyjbOl9im975jwFCbl7LSj3Y+0+vRlTG/JA4jNZhXsMJcAxeJpvr
pmm/IzN+uXNQzmKzBHVDw+mTUMPziRsUq4q6WrcuQFZa6kQFGNYWI/eWV8o4AAvp
HtrOGdSyU19w0QqPW0wHmhsV2XFcn6H/E1Qg6sxWpl45YWJFhNaITxm1EQIDAQAB
o0IwQDAOBgNVHQ8BAf8EBAMCAgQwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
bh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcNAQELBQADggIBAKvOh81Gag0r
0ipYS9aK6rp58b6jPpF6shr3xFiJQVovgSvxNS3aWolh+ZupTCC3H2Q1ZUgatak0
VyEJVO4a7Tz+1XlA6KErhnORC6HB/fgr5KEGraO3Q1uWonPal5QU8xHFStbRaXfx
hl/k4LLhIdJqcJE+XX/AL8ekZ3NPDtf9+k4V+RBuarLGuKgOtBB8+1qjSpClmW2B
DaWPlrLPOr2Sd29WOeWHifwVc6kBGpwM3g5VGdDsNX4Ba5eIG3lX2kUzJ8wNGEf0
bZxcVbTBY+D4JaV4WXoeFmajjK3EdizRpJRZw3fM0ZIeqVYysByNu/TovYLJnBPs
5AybnO4RzYONKJtZ1GtQgJyG+80/VffDJeBmHKEiYvE6mvOFEBAcU4VLU6sfwfT1
y1dZq5G9Km72Fg5kCuYDXTT+PB5VAV3Z6k819tG3TyI4hPlEphpoidRbZ+QS9tK5
RgHah9EJoM7tDAN/mUVHJHQhhLJDBn+iCBYgSJVLwoE+F39NO9oFPD/ZxhJkbk9b
LkFnpjrVbwD1CNnawX3I2Eytg1IbbzyviQIbpSAEpotk9pCLMAxTR3a08wrVMwst
2XVSrgK0uUKsZhCIc+q21k98aeNIINor15humizngyBWYOk8SqV84ZNcD6VlM3Qv
ShSKoAkdKxcGG1+MKPt5b7zqvTo8BBPM
-----END CERTIFICATE-----

View File

@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFDjCCAvagAwIBAgITc5Ejz7RzBJ2/PcUMsVhj41RtQDANBgkqhkiG9w0BAQsF
ADASMRAwDgYDVQQDDAdleGFtcGxlMB4XDTI0MDQyNTAxNDQ1N1oXDTI5MDQyNDAx
NDQ1N1owEDEOMAwGA1UEAwwFbmdpbngwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
ggIKAoICAQCgbLBnVrBdRkBF2XmJgDTiRqWFPQledzCrkHF4eiUvtEytJhkpoRv2
+SiRPsjCo3XjwcgQIgSy1sHUV8Sazn7V5ux/XBRovhdhUivzI8JSRYj6qwqdUnOy
dG1ZEy/VRLsIVfoFB0jKJrZCXMT256xkYTlsgPePDsduO7IPPrTN0/I/qBvINFet
zgWCl2qlZgF4c/MHljo2TR1KlBv0RJUZbfXPwemUazyMrh/MfQHaHE5pfrmMWFGA
6yLYHEhG+fy5d3F/1+4J24D2j7deIFmmuJMPSlAPt1UjDm7M/bmoTxDG+1MRXSnN
647EzzS0TFZspHe2+yBbw6j0MMiWMzNZX2iXGVcswXwrphe7ro6OITynM76gDTuM
ISYXKYHayqW0rHFRlKxMcnmrpf5tBuK7XKyoQv/LbFKI1e+j1bNVe7OZtC88EWRc
SD8WDLqo/3rsxJkRXRW/49hO1nynHrknXJEpZeRnTyglS+VCzXYD0XzwzPKN7CyN
CHpYpOcWrAMF+EJnE4WRVyJAAt4C1pGhiwn0yCvLEGXXedI/rR5zmUBKitSe7oMT
J82H/VaGtwH0lOD9Jjsv9cb+s1c3tChPDKvgGGDaFnlehKg9TM7p+xc9mnEsitfv
ovSGzYHk29nQu/S4QrPfWuCNwM2vP9OQ+VJyzDzSyH8iuPPmkfmK5wIDAQABo18w
XTAbBgNVHREEFDASggVuZ2lueIIJbG9jYWxob3N0MB0GA1UdDgQWBBT89oboWPBC
oNsSbaNquzrjTza6xDAfBgNVHSMEGDAWgBRuH1ODijHGcclNJprRYsFN6xjDETAN
BgkqhkiG9w0BAQsFAAOCAgEAeg8QwBTne1IGZMDvIGgs95lifzuTXGVQWEid7VVp
MmXGRYsweb0MwTUq3gSUc+3OPibR0i5HCJRR04H4U+cIjR6em1foIV/bW6nTaSls
xQAj92eMmzOo/KtOYqMnk//+Da5NvY0myWa/8FgJ7rK1tOZYiTZqFOlIsaiQMHgp
/PEkZBP5V57h0PY7T7tEj4SCw3DJ6qzzIdpD8T3+9kXd9dcrrjbivBkkJ23agcG5
wBcI862ELNJOD7p7+OFsv7IRsoXXYrydaDg8OJQovh4RccRqVEQu3hZdi7cPb8xJ
G7Gxn8SfSVcPg/UObiggydMl8E8QwqWAzJHvl1KUECd5QG6eq984JTR7zQB2iGb6
1qq+/d9uciuB2YY2h/0rl3Fjy6J6k3fpQK577TlJjZc0F4WH8fW5bcsyGTszxQLI
jQ6FuSOr55lZ9O3R3+95tAdJTrWsxX7j7xMIAXSYrfNt5HM91XNhqISF4SIZOBB6
enVrrJ/oCFqVSbYf6RVQz3XmPEEMh+k9KdwvIvwoS9NivLD3QH0RjhTyzHbf+LlR
rWM46XhmBwajlpnIuuMp6jZcXnbhTO1SheoRVMdijcnW+zrmx5oyn3peCfPqOVLz
95YfJUIFCt+0p/87/0Mm76uVemK6kFKZJQPnfbAdsKF7igPZfUQx6wZZP1qK9ZEU
eOk=
-----END CERTIFICATE-----

View File

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEAoGywZ1awXUZARdl5iYA04kalhT0JXncwq5BxeHolL7RMrSYZ
KaEb9vkokT7IwqN148HIECIEstbB1FfEms5+1ebsf1wUaL4XYVIr8yPCUkWI+qsK
nVJzsnRtWRMv1US7CFX6BQdIyia2QlzE9uesZGE5bID3jw7HbjuyDz60zdPyP6gb
yDRXrc4FgpdqpWYBeHPzB5Y6Nk0dSpQb9ESVGW31z8HplGs8jK4fzH0B2hxOaX65
jFhRgOsi2BxIRvn8uXdxf9fuCduA9o+3XiBZpriTD0pQD7dVIw5uzP25qE8QxvtT
EV0pzeuOxM80tExWbKR3tvsgW8Oo9DDIljMzWV9olxlXLMF8K6YXu66OjiE8pzO+
oA07jCEmFymB2sqltKxxUZSsTHJ5q6X+bQbiu1ysqEL/y2xSiNXvo9WzVXuzmbQv
PBFkXEg/Fgy6qP967MSZEV0Vv+PYTtZ8px65J1yRKWXkZ08oJUvlQs12A9F88Mzy
jewsjQh6WKTnFqwDBfhCZxOFkVciQALeAtaRoYsJ9MgryxBl13nSP60ec5lASorU
nu6DEyfNh/1WhrcB9JTg/SY7L/XG/rNXN7QoTwyr4Bhg2hZ5XoSoPUzO6fsXPZpx
LIrX76L0hs2B5NvZ0Lv0uEKz31rgjcDNrz/TkPlScsw80sh/Irjz5pH5iucCAwEA
AQKCAgADiEEeFV+OvjQ+FXrCl0sSzGFqnJxvMwqkTGrjLzVQZpTlnxggvYZjGrtU
71/2QSkgWazxBf66fVYJOeF/Uxqh1RLR/xIH+F+FagzDrr7hltxcQJXcPuuDO2MI
+g4skPXZSiNWJwHoSY/ryCUiFpnKIAXmqLRKtxWXDMNv6H6MpaUI18e80cI4dnfS
l0jm2Wcg4tSwDxO7DFmfwcEX0MbDp5Mo/ukIto+/vTnAA+Sdi9ACLKMjPvKUdxju
TzkcLvbskn+yQ+ve1bFyPFnaPbYboKbESGuY3P2H5xJzewayeQMyjmgW0slP2mbr
WHCdo6ynebuVENR2kMlQjx5riDcSMMX5TLGPgNL7ZBf2b52mUgFyQb27eO2WXeyH
YLtInlKA44bdi76sDK+s8zYywZnxsUy7xrKhHE5rqz964EfoLRcY/fCm7XnMo6uK
VviBtdPebsMqkZOUKSaYSRpUgXILTud5FD+m68FeVjUvQFQqHYEa3gx+rAIjKBIn
082NzfDZSHVsvG+iB5q+37R8C0/YUzSb3TXys5pA82YsjIFeQiVE4hrV1yeNIZf6
2iaPD/r5H3vt0rFEDINZafC+6bTTRQoq8TOCZFh/Lu+ynXKOPrVUF8/y3sd8+T2v
kRDOL37reUotjE1lbO4RhLgHbeWHlT/PPnF7RDKCe6/erg2MqQKCAQEAy3f8B6I8
7CP4CZmMDWwHWsjMS/HGZgvPPbmWhaeZZmFyYi7I8MruJPhlhlw6YoUIV9Vvp8zE
eLtDvZ5WXuL38aRElWzNyrhrU1/vH4pkaFk+OgRcaleGUof+go0lE8BIYnWoWovo
/F7lQMQmHY4SuwF4oj6dpus7jMm41PQqDTsjofdLgwVAGy30LIkVt8qYha77sL8N
0ohXomDGik0nVa+i2mOJ0UuooGYF8WhujzVcELcerYvvg9kFDqJaEXdfTx4DRwiz
6f5gSbZHME7moqEkcJRtwj8TXSJYRHTI8ngS0xzyV0u2RL3FOxTcgikJIkmU6W3L
IcbP6XVlrCdoswKCAQEAydfBcsYcS2mMqCOdKkGVj6zBriT78/5dtPYeId9WkrnX
1vz6ErjHQ8vZkduvCm3KkijQvva+DFV0sv24qTyA2BIoDUJdk7cY962nR4Q9FHTX
Dkn1kgeKg4TtNdgo2KsIUn7bCibKASCExo6rO3PWiQyF+jTJVDD3rXx7+7N7WJaz
zTVt6BNOWoIjTufdXfRWt3wi0H6sSkqvRWoIAaguXkKXH7oBx0gKs+oAVovFvg7A
LLEtTszsv2LmbpGWaiT3Ny215mA0ZGI9T4utK7oUgd+DlV0+vj5tFfsye4COpCyG
V/ZQ7CBbxHDDak3R3fYy5pOwmh6814wHMyKKfdGm/QKCAQEAiW4Pk3BnyfA5lvJZ
gK9ZAF7kbt9tbHvJjR2Pp9Meb+KeCecj3lCTLfGBUZF19hl5GyqU8jgC9LE3/hm2
qPyREGwtzufg0G5kP7pqn1kwnLK6ryFG8qUPmys0IyYGxyJ3QdnKzu31fpDyNB7I
x+mwiRNjUeMNRTNZ06xk5aHNzYYGeV25aVPgivstE++79ZooDxOz+Rvy0CM7XfgT
4lJeoSeyzeOxsOZzjXObzAUHuD8IYlntpLcCHoI1Qj8yqt2ASMYy3IXqT8B7dQ5j
YyPH8Ez7efcnc656+8s453QiTnP/8wx4O7Jt+FxdnZxnnJrvCnO82zZHoBbTVBLx
i6hKtQKCAQA0j3SWmLRBhwjTuAJzQITb1xbQbF0X2oM4XmbWVzxKFQ75swLD4U4y
f2D2tIhOZOy9RtelAsfWmmI7QgrWNyUuHvxDB6cqkiF0Tcoju3HUY+CknenOzxvo
x7KltNZeJZuTL+mGKTetN3Sb6Ab7Al05bwNsdlZ/EAlPKf13O/PAy+2iYGlwZ6ad
twnOwF5K2xfBzBecx3/CENS3dLcFB3CbpyeHYX6ZEE+JLkRMRTWHGnw8px6vSHnW
FMEAxfSvS1T9D3Awv5ilE1f34N2FZ31znGq9eHygOc1aTgGFW6LJabbKLSBBfOOo
sdyRUBZ4gGYc2RTB7YMrdhFh5Xq+7NtZAoIBAQCOJ3CLecp/rS+lGy7oyx4f6QDd
zH/30Y/uvXLPUj+Ljg9bMTG9chjaKfyApXv6rcQI0d6wrqAunNl1b3opBQjsGCSt
bpBV/rGg3sl752og6KU1PCZ2KkVYPjugNhqPGonNh8tlw+1xFyBdt0c68g/auIHq
WaT5tWVfP01Ri43RjyCgNtJ2TJUzbA40BteDHPWKeM1lZ6e92fJTp5IjQ/Okc41u
Elr7p22fx/N04JTX9G6oGdxM7Gh2Uf4i4PnNOi+C3xqLrtUEi/OLof2UHlatypt9
pix0bXJtZE7WfFfesQIxGffVBhgN3UgqhAf2wquHgm1O17JXrmkR6JSYNpKc
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,13 @@
endpoints:
- name: example
url: https://nginx
interval: 30s
conditions:
- "[STATUS] == 200"
client:
# mtls
insecure: true
tls:
certificate-file: /certs/client.crt
private-key-file: /certs/client.key
renegotiation: once

View File

@ -0,0 +1,27 @@
version: "3.9"
services:
nginx:
image: nginx:stable
volumes:
- ./certs/server:/etc/nginx/certs
- ./nginx:/etc/nginx/conf.d
ports:
- "8443:443"
networks:
- mtls
gatus:
image: twinproduction/gatus:latest
restart: always
ports:
- "8080:8080"
volumes:
- ./config:/config
- ./certs/client:/certs
environment:
- GATUS_CONFIG_PATH=/config
networks:
- mtls
networks:
mtls:

View File

@ -0,0 +1,16 @@
server {
listen 443 ssl;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_client_certificate /etc/nginx/certs/ca.crt;
ssl_verify_client on;
location / {
if ($ssl_client_verify != SUCCESS) {
return 403;
}
root /usr/share/nginx/html;
index index.html index.htm;
}
}

BIN
.github/assets/gitea-alerts.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -22,7 +22,7 @@ jobs:
steps: steps:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: 1.19 go-version: 1.22.2
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}" repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}" ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@ -18,7 +18,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64 platforms: linux/amd64
pull: true pull: true

View File

@ -30,9 +30,9 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest tags: ${{ env.IMAGE_REPOSITORY }}:latest

View File

@ -26,9 +26,9 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest tags: ${{ env.IMAGE_REPOSITORY }}:latest

View File

@ -26,9 +26,9 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: |

View File

@ -23,9 +23,9 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: |

View File

@ -18,7 +18,7 @@ jobs:
steps: steps:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: 1.21 go-version: 1.22.2
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build binary to make sure it works - name: Build binary to make sure it works
run: go build run: go build
@ -28,7 +28,7 @@ jobs:
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable) # was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov - name: Codecov
uses: codecov/codecov-action@v4.3.0 uses: codecov/codecov-action@v4.5.0
with: with:
files: ./coverage.txt files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}

188
README.md
View File

@ -1,14 +1,12 @@
[![Gatus](.github/assets/logo-with-dark-text.png)](https://gatus.io) [![Gatus](.github/assets/logo-with-dark-text.png)](https://gatus.io)
![test](https://github.com/TwiN/gatus/workflows/test/badge.svg?branch=master) ![test](https://github.com/TwiN/gatus/workflows/test/badge.svg?branch=master)
[![Join Discord server](https://img.shields.io/discord/442432928614449155.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/ka9RySaQ9K)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus) [![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus)
[![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus) [![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus)
[![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus) [![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus)
[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus) [![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)
[![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN) [![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN)
Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
queries as well as evaluate the result of said queries by using a list of conditions on values like the status code, queries as well as evaluate the result of said queries by using a list of conditions on values like the status code,
the response time, the certificate expiration, the body and many others. The icing on top is that each of these health the response time, the certificate expiration, the body and many others. The icing on top is that each of these health
@ -55,6 +53,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Alerting](#alerting) - [Alerting](#alerting)
- [Configuring Discord alerts](#configuring-discord-alerts) - [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts) - [Configuring Email alerts](#configuring-email-alerts)
- [Configuring Gitea alerts](#configuring-gitea-alerts)
- [Configuring GitHub alerts](#configuring-github-alerts) - [Configuring GitHub alerts](#configuring-github-alerts)
- [Configuring GitLab alerts](#configuring-gitlab-alerts) - [Configuring GitLab alerts](#configuring-gitlab-alerts)
- [Configuring Google Chat alerts](#configuring-google-chat-alerts) - [Configuring Google Chat alerts](#configuring-google-chat-alerts)
@ -68,11 +67,13 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts) - [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
- [Configuring Pushover alerts](#configuring-pushover-alerts) - [Configuring Pushover alerts](#configuring-pushover-alerts)
- [Configuring Slack alerts](#configuring-slack-alerts) - [Configuring Slack alerts](#configuring-slack-alerts)
- [Configuring Teams alerts](#configuring-teams-alerts) - [Configuring Teams alerts *(Deprecated)*](#configuring-teams-alerts-deprecated)
- [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts)
- [Configuring Telegram alerts](#configuring-telegram-alerts) - [Configuring Telegram alerts](#configuring-telegram-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts) - [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts) - [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring custom alerts](#configuring-custom-alerts) - [Configuring custom alerts](#configuring-custom-alerts)
- [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Setting a default alert](#setting-a-default-alert) - [Setting a default alert](#setting-a-default-alert)
- [Maintenance](#maintenance) - [Maintenance](#maintenance)
- [Security](#security) - [Security](#security)
@ -110,6 +111,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring a startup delay](#configuring-a-startup-delay) - [Configuring a startup delay](#configuring-a-startup-delay)
- [Keeping your configuration small](#keeping-your-configuration-small) - [Keeping your configuration small](#keeping-your-configuration-small)
- [Proxy client configuration](#proxy-client-configuration) - [Proxy client configuration](#proxy-client-configuration)
- [How to fix 431 Request Header Fields Too Large error](#how-to-fix-431-request-header-fields-too-large-error)
- [Badges](#badges) - [Badges](#badges)
- [Uptime](#uptime) - [Uptime](#uptime)
- [Health](#health) - [Health](#health)
@ -307,12 +309,13 @@ external-endpoints:
To push the status of an external endpoint, the request would have to look like this: To push the status of an external endpoint, the request would have to look like this:
``` ```
POST /api/v1/endpoints/{key}/external?success={success} POST /api/v1/endpoints/{key}/external?success={success}&error={error}
``` ```
Where: Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. - `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
- Using the example configuration above, the key would be `core_ext-ep-test`. - Using the example configuration above, the key would be `core_ext-ep-test`.
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not. - `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
- `{error}`: 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, it can be omitted or left empty.
You must also pass the token as a `Bearer` token in the `Authorization` header. You must also pass the token as a `Bearer` token in the `Authorization` header.
@ -565,7 +568,8 @@ endpoints:
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` | | `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.pushover` | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` | | `alerting.pushover` | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` | | `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` | | `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)* <br />See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. <br />See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` | | `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
@ -662,8 +666,45 @@ endpoints:
> ⚠ Some mail servers are painfully slow. > ⚠ Some mail servers are painfully slow.
#### Configuring Gitea alerts
| Parameter | Description | Default |
|:---------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|
| `alerting.gitea` | Configuration for alerts of type `gitea` | `{}` |
| `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`) | Required `""` |
| `alerting.gitea.token` | Personal access token to use for authentication. <br />Must have at least RW on issues and RO on metadata. | Required `""` |
| `alerting.github.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
The Gitea alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display
name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically
closed when the alert is resolved.
```yaml
alerting:
gitea:
repository-url: "https://gitea.com/TwiN/test"
token: "349d63f16......"
endpoints:
- name: example
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 75"
alerts:
- type: gitea
failure-threshold: 2
success-threshold: 3
send-on-resolved: true
description: "Everything's burning AAAAAHHHHHHHHHHHHHHH"
```
![Gitea alert](.github/assets/gitea-alerts.png)
#### Configuring GitHub alerts #### Configuring GitHub alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:---------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------| |:---------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|
| `alerting.github` | Configuration for alerts of type `github` | `{}` | | `alerting.github` | Configuration for alerts of type `github` | `{}` |
@ -699,7 +740,6 @@ endpoints:
![GitHub alert](.github/assets/github-alerts.png) ![GitHub alert](.github/assets/github-alerts.png)
#### Configuring GitLab alerts #### Configuring GitLab alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------------------------|:--------------------------------------------------------------------------------------------------------------------|:--------------| |:------------------------------------|:--------------------------------------------------------------------------------------------------------------------|:--------------|
@ -879,6 +919,7 @@ endpoints:
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------| |:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` | | `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` | | `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
| `alerting.mattermost.channel` | Mattermost channel name override (optional) | `""` |
| `alerting.mattermost.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` | | `alerting.mattermost.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A | | `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | | `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
@ -946,12 +987,16 @@ endpoints:
#### Configuring Ntfy alerts #### Configuring Ntfy alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------------------|:-------------------------------------------------------------------------------------------|:------------------| |:---------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` | | `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` | | `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` | | `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` | | `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` |
| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` |
| `alerting.ntfy.click` | Website opened when notification is clicked | `""` |
| `alerting.ntfy.priority` | The priority of the alert | `3` | | `alerting.ntfy.priority` | The priority of the alert | `3` |
| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` |
| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` |
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
[ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop [ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop
@ -1134,20 +1179,29 @@ Here's an example of what the notifications look like:
![Slack notifications](.github/assets/slack-alerts.png) ![Slack notifications](.github/assets/slack-alerts.png)
#### Configuring Teams alerts #### Configuring Teams alerts *(Deprecated)*
> [!CAUTION]
> **Deprecated:** Office 365 Connectors within Microsoft Teams are being retired ([Source: Microsoft DevBlog](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)).
> Existing connectors will continue to work until December 2025. The new [Teams Workflow Alerts](#configuring-teams-workflow-alerts) should be used with Microsoft Workflows instead of this legacy configuration.
| Parameter | Description | Default | | Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------------|
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` | | `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` | | `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | | `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams.title` | Title of the notification | `"&#x1F6A8; Gatus"` |
| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | | `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` | | `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` |
| `alerting.teams.client.insecure` | Whether to skip TLS verification | `false` |
```yaml ```yaml
alerting: alerting:
teams: teams:
webhook-url: "https://********.webhook.office.com/webhookb2/************" webhook-url: "https://********.webhook.office.com/webhookb2/************"
client:
insecure: false
# You can also add group-specific to keys, which will # You can also add group-specific to keys, which will
# override the to key above for the specified groups # override the to key above for the specified groups
overrides: overrides:
@ -1184,16 +1238,75 @@ Here's an example of what the notifications look like:
![Teams notifications](.github/assets/teams-alerts.png) ![Teams notifications](.github/assets/teams-alerts.png)
#### Configuring Teams Workflow alerts
> [!NOTE]
> This alert is compatible with Workflows for Microsoft Teams. To setup the workflow and get the webhook URL, follow the [Microsoft Documentation](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498).
| Parameter | Description | Default |
|:---------------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------|
| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams-workflows.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams-workflows.title` | Title of the notification | `"&#x26D1; Gatus"` |
| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams-workflows.overrides[].webhook-url` | Teams WorkFlow Webhook URL | `""` |
```yaml
alerting:
teams-workflows:
webhook-url: "https://********.webhook.office.com/webhookb2/************"
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "core"
webhook-url: "https://********.webhook.office.com/webhookb3/************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: teams-workflows
description: "healthcheck failed"
send-on-resolved: true
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: teams-workflows
description: "healthcheck failed"
send-on-resolved: true
```
Here's an example of what the notifications look like:
![Teams Workflow notifications](.github/assets/teams-workflows-alerts.png)
#### Configuring Telegram alerts #### Configuring Telegram alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------| |:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` | | `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` | | `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` | | `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` | | `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.telegram.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.telegram.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.telegram.overrides[].token` | Telegram Bot Token for override default value | `""` |
| `alerting.telegram.overrides[].id` | Telegram User ID for override default value | `""` |
```yaml ```yaml
alerting: alerting:
@ -1315,6 +1428,7 @@ Furthermore, you may use the following placeholders in the body (`alerting.custo
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`) - `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`) - `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`) - `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
- `[RESULT_ERRORS]` (resolved from the health evaluation of a given health check)
If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications. `[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.
@ -1329,7 +1443,7 @@ alerting:
method: "POST" method: "POST"
body: | body: |
{ {
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION]" "text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
} }
endpoints: endpoints:
- name: website - name: website
@ -1450,6 +1564,42 @@ endpoints:
- type: pagerduty - type: pagerduty
``` ```
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------------|:------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.zulip` | Configuration for alerts of type `discord` | `{}` |
| `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 `""` |
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.zulip.overrides[].bot-email` | . | `""` |
| `alerting.zulip.overrides[].bot-api-key` | . | `""` |
| `alerting.zulip.overrides[].domain` | . | `""` |
| `alerting.zulip.overrides[].channel-id` | . | `""` |
```yaml
alerting:
zulip:
bot-email: gatus-bot@some.zulip.org
bot-api-key: "********************************"
domain: some.zulip.org
channel-id: 123456
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: zulip
description: "healthcheck failed"
send-on-resolved: true
```
### Maintenance ### Maintenance
If you have maintenance windows, you may not want to be annoyed by alerts. If you have maintenance windows, you may not want to be annoyed by alerts.
@ -1460,15 +1610,15 @@ To do that, you'll have to use the maintenance configuration:
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` | | `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` | | `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` | | `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
| `maintenance.timezone` | Timezone of the maintenance window format (e.g. `Europe/Amsterdam`).<br />See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more info | `UTC` |
| `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day | `[]` | | `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day | `[]` |
> 📝 The maintenance configuration uses UTC
Here's an example: Here's an example:
```yaml ```yaml
maintenance: maintenance:
start: 23:00 start: 23:00
duration: 1h duration: 1h
timezone: "Europe/Amsterdam"
every: [Monday, Thursday] every: [Monday, Thursday]
``` ```
Note that you can also specify each day on separate lines: Note that you can also specify each day on separate lines:
@ -1476,6 +1626,7 @@ Note that you can also specify each day on separate lines:
maintenance: maintenance:
start: 23:00 start: 23:00
duration: 1h duration: 1h
timezone: "Europe/Amsterdam"
every: every:
- Monday - Monday
- Thursday - Thursday
@ -1646,11 +1797,12 @@ Please refer to Helm's [documentation](https://helm.sh/docs/) to get started.
Once Helm is set up properly, add the repository as follows: Once Helm is set up properly, add the repository as follows:
```console ```console
helm repo add minicloudlabs https://minicloudlabs.github.io/helm-charts helm repo add twin https://twin.github.io/helm-charts
helm repo update
helm install gatus twin/gatus
``` ```
To get more details, please check [chart's configuration](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#configuration) To get more details, please check [chart's configuration](https://github.com/TwiN/helm-charts/blob/master/charts/gatus/README.md).
and [helm file example](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#helmfileyaml-example)
### Terraform ### Terraform
@ -2123,7 +2275,7 @@ The path to generate a badge is the following:
/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg /api/v1/endpoints/{key}/uptimes/{duration}/badge.svg
``` ```
Where: Where:
- `{duration}` is `7d`, `24h` or `1h` - `{duration}` is `30d` (alpha), `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. - `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`, For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
@ -2188,7 +2340,7 @@ The endpoint to generate a badge is the following:
/api/v1/endpoints/{key}/response-times/{duration}/badge.svg /api/v1/endpoints/{key}/response-times/{duration}/badge.svg
``` ```
Where: Where:
- `{duration}` is `7d`, `24h` or `1h` - `{duration}` is `30d` (alpha), `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 `-`.

View File

@ -1,7 +1,10 @@
package alert package alert
import ( import (
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"strconv"
"strings" "strings"
) )
@ -10,7 +13,7 @@ var (
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\") ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
) )
// Alert is a core.Endpoint's alert configuration // Alert is a endpoint.Endpoint's alert configuration
type Alert struct { type Alert struct {
// Type of alert (required) // Type of alert (required)
Type Type `yaml:"type"` Type Type `yaml:"type"`
@ -26,6 +29,9 @@ type Alert struct {
// FailureThreshold is the number of failures in a row needed before triggering the alert // FailureThreshold is the number of failures in a row needed before triggering the alert
FailureThreshold int `yaml:"failure-threshold"` FailureThreshold int `yaml:"failure-threshold"`
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"`
// Description of the alert. Will be included in the alert sent. // Description of the alert. Will be included in the alert sent.
// //
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value // This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
@ -38,9 +44,6 @@ type Alert struct {
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer // or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
SendOnResolved *bool `yaml:"send-on-resolved"` SendOnResolved *bool `yaml:"send-on-resolved"`
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"`
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve // ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
// ongoing/triggered incidents // ongoing/triggered incidents
ResolveKey string `yaml:"-"` ResolveKey string `yaml:"-"`
@ -94,3 +97,17 @@ func (alert *Alert) IsSendingOnResolved() bool {
} }
return *alert.SendOnResolved return *alert.SendOnResolved
} }
// Checksum returns a checksum of the alert
// Used to determine which persisted triggered alert should be deleted on application start
func (alert *Alert) Checksum() string {
hash := sha256.New()
hash.Write([]byte(string(alert.Type) + "_" +
strconv.FormatBool(alert.IsEnabled()) + "_" +
strconv.FormatBool(alert.IsSendingOnResolved()) + "_" +
strconv.Itoa(alert.SuccessThreshold) + "_" +
strconv.Itoa(alert.FailureThreshold) + "_" +
alert.GetDescription()),
)
return hex.EncodeToString(hash.Sum(nil))
}

View File

@ -84,3 +84,109 @@ func TestAlert_IsSendingOnResolved(t *testing.T) {
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true") t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
} }
} }
func TestAlert_Checksum(t *testing.T) {
description1, description2 := "a", "b"
yes, no := true, false
scenarios := []struct {
name string
alert Alert
expected string
}{
{
name: "barebone",
alert: Alert{
Type: TypeDiscord,
},
expected: "fed0580e44ed5701dbba73afa1f14b2c53ca5a7b8067a860441c212916057fe3",
},
{
name: "with-description-1",
alert: Alert{
Type: TypeDiscord,
Description: &description1,
},
expected: "005f407ebe506e74a4aeb46f74c28b376debead7011e1b085da3840f72ba9707",
},
{
name: "with-description-2",
alert: Alert{
Type: TypeDiscord,
Description: &description2,
},
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
},
{
name: "with-description-2-and-enabled-false",
alert: Alert{
Type: TypeDiscord,
Enabled: &no,
Description: &description2,
},
expected: "837945c2b4cd5e961db3e63e10c348d4f1c3446ba68cf5a48e35a1ae22cf0c22",
},
{
name: "with-description-2-and-enabled-true",
alert: Alert{
Type: TypeDiscord,
Enabled: &yes, // it defaults to true if not set, but just to make sure
Description: &description2,
},
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
},
{
name: "with-description-2-and-enabled-true-and-send-on-resolved-true",
alert: Alert{
Type: TypeDiscord,
Enabled: &yes,
SendOnResolved: &yes,
Description: &description2,
},
expected: "bf1436995a880eb4a352c74c5dfee1f1b5ff6b9fc55aef9bf411b3631adfd80c",
},
{
name: "with-description-2-and-failure-threshold-7",
alert: Alert{
Type: TypeSlack,
FailureThreshold: 7,
Description: &description2,
},
expected: "8bd479e18bda393d4c924f5a0d962e825002168dedaa88b445e435db7bacffd3",
},
{
name: "with-description-2-and-failure-threshold-9",
alert: Alert{
Type: TypeSlack,
FailureThreshold: 9,
Description: &description2,
},
expected: "5abdfce5236e344996d264d526e769c07cb0d3d329a999769a1ff84b157ca6f1",
},
{
name: "with-description-2-and-success-threshold-5",
alert: Alert{
Type: TypeSlack,
SuccessThreshold: 7,
Description: &description2,
},
expected: "c0000e73626b80e212cfc24830de7094568f648e37f3e16f9e68c7f8ef75c34c",
},
{
name: "with-description-2-and-success-threshold-1",
alert: Alert{
Type: TypeSlack,
SuccessThreshold: 1,
Description: &description2,
},
expected: "5c28963b3a76104cfa4a0d79c89dd29ec596c8cfa4b1af210ec83d6d41587b5f",
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
scenario.alert.ValidateAndSetDefaults()
if checksum := scenario.alert.Checksum(); checksum != scenario.expected {
t.Errorf("expected checksum %v, got %v", scenario.expected, checksum)
}
})
}
}

View File

@ -23,6 +23,9 @@ const (
// TypeGitLab is the Type for the gitlab alerting provider // TypeGitLab is the Type for the gitlab alerting provider
TypeGitLab Type = "gitlab" TypeGitLab Type = "gitlab"
// TypeGitea is the Type for the gitea alerting provider
TypeGitea Type = "gitea"
// TypeGoogleChat is the Type for the googlechat alerting provider // TypeGoogleChat is the Type for the googlechat alerting provider
TypeGoogleChat Type = "googlechat" TypeGoogleChat Type = "googlechat"
@ -64,4 +67,7 @@ const (
// TypeTwilio is the Type for the twilio alerting provider // TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio" TypeTwilio Type = "twilio"
// TypeZulip is the Type for the Zulip alerting provider
TypeZulip Type = "zulip"
) )

View File

@ -11,6 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/googlechat"
@ -25,8 +26,10 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
) )
// Config is the configuration for alerting providers // Config is the configuration for alerting providers
@ -49,6 +52,9 @@ type Config struct {
// GitLab is the configuration for the gitlab alerting provider // GitLab is the configuration for the gitlab alerting provider
GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"` GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"`
// Gitea is the configuration for the gitea alerting provider
Gitea *gitea.AlertProvider `yaml:"gitea,omitempty"`
// GoogleChat is the configuration for the googlechat alerting provider // GoogleChat is the configuration for the googlechat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"` GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
@ -85,11 +91,17 @@ type Config struct {
// Teams is the configuration for the teams alerting provider // Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams,omitempty"` Teams *teams.AlertProvider `yaml:"teams,omitempty"`
// TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector
TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"`
// Telegram is the configuration for the telegram alerting provider // Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"` Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
// Twilio is the configuration for the twilio alerting provider // Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"` Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Zulip is the configuration for the zulip alerting provider
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
} }
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type // GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials"
@ -57,14 +57,14 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
sess, err := provider.createSession() sess, err := provider.createSession()
if err != nil { if err != nil {
return err return err
} }
svc := ses.New(sess) svc := ses.New(sess)
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(provider.getToForGroup(endpoint.Group), ",") emails := strings.Split(provider.getToForGroup(ep.Group), ",")
input := &ses.SendEmailInput{ input := &ses.SendEmailInput{
Destination: &ses.Destination{ Destination: &ses.Destination{
@ -110,14 +110,14 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// buildMessageSubjectAndBody builds the message subject and body // buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) { func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string var subject, message string
if resolved { if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {

View File

@ -4,7 +4,7 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
@ -95,10 +95,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody( subject, body := scenario.Provider.buildMessageSubjectAndBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request // AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
@ -50,16 +50,18 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri
return status return status
} }
func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request { func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
body, url, method := provider.Body, provider.URL, provider.Method body, url, method := provider.Body, provider.URL, provider.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription()) body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription()) url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name) body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name) url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group) body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group) url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL) body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL) url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
if resolved { if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true)) body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true)) url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
@ -78,8 +80,8 @@ func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *
return request return request
} }
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
request := provider.buildHTTPRequest(endpoint, alert, resolved) request := provider.buildHTTPRequest(ep, alert, result, resolved)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err

View File

@ -8,7 +8,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -90,10 +90,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -138,8 +138,55 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := customAlertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: []string{}},
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 TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
customAlertWithErrorsProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
Errors []string
}{
{
AlertProvider: customAlertWithErrorsProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,",
},
{
AlertProvider: customAlertWithErrorsProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
Errors: []string{"error1", "error2"},
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
request := customAlertWithErrorsProvider.buildHTTPRequest(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: scenario.Errors},
scenario.Resolved, scenario.Resolved,
) )
if request.URL.String() != scenario.ExpectedURL { if request.URL.String() != scenario.ExpectedURL {
@ -188,8 +235,9 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := customAlertProvider.buildHTTPRequest(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
&endpoint.Result{},
scenario.Resolved, scenario.Resolved,
) )
if request.URL.String() != scenario.ExpectedURL { if request.URL.String() != scenario.ExpectedURL {

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Discord // AlertProvider is the configuration necessary for sending an alert using Discord
@ -47,9 +47,9 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@ -85,14 +85,14 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
var colorCode int var colorCode int
if resolved { if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.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)
colorCode = 3066993 colorCode = 3066993
} else { } else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.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)
colorCode = 15158332 colorCode = 15158332
} }
var formattedConditionResults string var formattedConditionResults string

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -127,10 +127,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -191,18 +191,18 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
{Condition: "[BODY] != \"\"", Success: scenario.Resolved}, {Condition: "[BODY] != \"\"", Success: scenario.Resolved},
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: conditionResults, ConditionResults: conditionResults,
}, },
scenario.Resolved, scenario.Resolved,

View File

@ -8,7 +8,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
gomail "gopkg.in/mail.v2" gomail "gopkg.in/mail.v2"
) )
@ -53,17 +53,17 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
var username string var username string
if len(provider.Username) > 0 { if len(provider.Username) > 0 {
username = provider.Username username = provider.Username
} else { } else {
username = provider.From username = provider.From
} }
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", provider.From) m.SetHeader("From", provider.From)
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...) m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/plain", body) m.SetBody("text/plain", body)
var d *gomail.Dialer var d *gomail.Dialer
@ -87,14 +87,14 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// buildMessageSubjectAndBody builds the message subject and body // buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) { func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string var subject, message string
if resolved { if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName()) subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {

View File

@ -4,7 +4,7 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
@ -97,10 +97,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody( subject, body := scenario.Provider.buildMessageSubjectAndBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -0,0 +1,167 @@
package gitea
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"strings"
"code.gitea.io/sdk/gitea"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// Assignees is a list of users to assign the issue to
Assignees []string `yaml:"assignees,omitempty"`
username string
repositoryOwner string
repositoryName string
giteaClient *gitea.Client
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
return false
}
// Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL)
if err != nil {
return false
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return false
}
provider.repositoryOwner = pathParts[1]
provider.repositoryName = pathParts[2]
opts := []gitea.ClientOption{
gitea.SetToken(provider.Token),
}
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
// add new http client for skip verify
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
opts = append(opts, gitea.SetHTTPClient(httpClient))
}
provider.giteaClient, err = gitea.NewClient(baseURL, opts...)
if err != nil {
return false
}
user, _, err := provider.giteaClient.GetMyUserInfo()
if err != nil {
return false
}
provider.username = user.UserName
return true
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
title := "alert(gatus): " + ep.DisplayName()
if !resolved {
_, _, err := provider.giteaClient.CreateIssue(
provider.repositoryOwner,
provider.repositoryName,
gitea.CreateIssueOption{
Title: title,
Body: provider.buildIssueBody(ep, alert, result),
Assignees: provider.Assignees,
},
)
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
return nil
}
issues, _, err := provider.giteaClient.ListRepoIssues(
provider.repositoryOwner,
provider.repositoryName,
gitea.ListIssueOption{
State: gitea.StateOpen,
CreatedBy: provider.username,
ListOptions: gitea.ListOptions{
Page: 100,
},
},
)
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
for _, issue := range issues {
if issue.Title == title {
stateClosed := gitea.StateClosed
_, _, err = provider.giteaClient.EditIssue(
provider.repositoryOwner,
provider.repositoryName,
issue.ID,
gitea.EditIssueOption{
State: &stateClosed,
},
)
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
}
}
return nil
}
// buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
return message + description + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@ -0,0 +1,169 @@
package gitea
import (
"net/http"
"strings"
"testing"
"code.gitea.io/sdk/gitea"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
Expected bool
}{
{
Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""},
Expected: false,
},
{
Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
Expected: false,
},
{
Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"},
Expected: false,
},
{
Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"},
Expected: false,
},
{
Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"},
Expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected {
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered-error",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
},
{
Name: "resolved-error",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.giteaClient, _ = gitea.NewClient("https://gitea.com")
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
NoConditions bool
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
}
}
body := scenario.Provider.buildIssueBody(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
)
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@ -8,7 +8,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@ -70,12 +70,12 @@ func (provider *AlertProvider) IsValid() bool {
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
title := "alert(gatus): " + endpoint.DisplayName() title := "alert(gatus): " + ep.DisplayName()
if !resolved { if !resolved {
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{ _, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{
Title: github.String(title), Title: github.String(title),
Body: github.String(provider.buildIssueBody(endpoint, alert, result)), Body: github.String(provider.buildIssueBody(ep, alert, result)),
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to create issue: %w", err) return fmt.Errorf("failed to create issue: %w", err)
@ -104,7 +104,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// buildIssueBody builds the body of the issue // buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result) string { func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n" formattedConditionResults = "\n\n## Condition results\n"
@ -122,7 +122,7 @@ func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *al
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.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)
return message + description + formattedConditionResults return message + description + formattedConditionResults
} }

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
) )
@ -85,10 +85,10 @@ func TestAlertProvider_Send(t *testing.T) {
scenario.Provider.githubClient = github.NewClient(nil) scenario.Provider.githubClient = github.NewClient(nil)
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -109,7 +109,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
scenarios := []struct { scenarios := []struct {
Name string Name string
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
NoConditions bool NoConditions bool
@ -117,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`", ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
}, },
{ {
Name: "triggered-with-no-description", Name: "triggered-with-no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10}, Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`", ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
@ -132,7 +132,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered-with-no-conditions", Name: "triggered-with-no-conditions",
NoConditions: true, NoConditions: true,
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1", ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
@ -140,9 +140,9 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true}, {Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false}, {Condition: "[STATUS] == 200", Success: false},
} }
@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
body := scenario.Provider.buildIssueBody( body := scenario.Provider.buildIssueBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
) )
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) { if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)

View File

@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -51,11 +51,11 @@ func (provider *AlertProvider) IsValid() bool {
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
if len(alert.ResolveKey) == 0 { if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString() alert.ResolveKey = uuid.NewString()
} }
buffer := bytes.NewBuffer(provider.buildAlertBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
@ -94,21 +94,21 @@ func (provider *AlertProvider) monitoringTool() string {
return "gatus" return "gatus"
} }
func (provider *AlertProvider) service(endpoint *core.Endpoint) string { func (provider *AlertProvider) service(ep *endpoint.Endpoint) string {
if len(provider.Service) > 0 { if len(provider.Service) > 0 {
return provider.Service return provider.Service
} }
return endpoint.DisplayName() return ep.DisplayName()
} }
// buildAlertBody builds the body of the alert // buildAlertBody builds the body of the alert
func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := AlertBody{ body := AlertBody{
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(endpoint)), Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(ep)),
StartTime: result.Timestamp.Format(time.RFC3339), StartTime: result.Timestamp.Format(time.RFC3339),
Service: provider.service(endpoint), Service: provider.service(ep),
MonitoringTool: provider.monitoringTool(), MonitoringTool: provider.monitoringTool(),
Hosts: endpoint.URL, Hosts: ep.URL,
GitlabEnvironmentName: provider.EnvironmentName, GitlabEnvironmentName: provider.EnvironmentName,
Severity: provider.Severity, Severity: provider.Severity,
Fingerprint: alert.ResolveKey, Fingerprint: alert.ResolveKey,
@ -135,9 +135,9 @@ func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *al
} }
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.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)
} }
body.Description = message + description + formattedConditionResults body.Description = message + description + formattedConditionResults
bodyAsJSON, _ := json.Marshal(body) bodyAsJSON, _ := json.Marshal(body)

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -84,10 +84,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -108,21 +108,21 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
scenarios := []struct { scenarios := []struct {
Name string Name string
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
ExpectedBody string ExpectedBody string
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
}, },
{ {
Name: "no-description", Name: "no-description",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10}, Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
@ -133,8 +133,8 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
body := scenario.Provider.buildAlertBody( body := scenario.Provider.buildAlertBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true}, {Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false}, {Condition: "[STATUS] == 200", Success: false},
}, },

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Google chat // AlertProvider is the configuration necessary for sending an alert using Google chat
@ -50,9 +50,9 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@ -112,7 +112,7 @@ type OpenLink struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { if resolved {
color = "#36A64F" color = "#36A64F"
@ -143,7 +143,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Widgets: []Widgets{ Widgets: []Widgets{
{ {
KeyValue: &KeyValue{ KeyValue: &KeyValue{
TopLabel: endpoint.DisplayName(), TopLabel: ep.DisplayName(),
Content: message, Content: message,
ContentMultiline: "true", ContentMultiline: "true",
BottomLabel: description, BottomLabel: description,
@ -166,7 +166,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
}, },
}) })
} }
if endpoint.Type() == core.EndpointTypeHTTP { if ep.Type() == endpoint.TypeHTTP {
// We only include a button targeting the URL if the endpoint is an HTTP endpoint // We only include a button targeting the URL if the endpoint is an HTTP endpoint
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways. // If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
// See https://github.com/TwiN/gatus/issues/362 // See https://github.com/TwiN/gatus/issues/362
@ -175,7 +175,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
{ {
TextButton: TextButton{ TextButton: TextButton{
Text: "URL", Text: "URL",
OnClick: OnClick{OpenLink: OpenLink{URL: endpoint.URL}}, OnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}},
}, },
}, },
}, },

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -141,7 +141,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
secondDescription := "description-2" secondDescription := "description-2"
scenarios := []struct { scenarios := []struct {
Name string Name string
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
@ -149,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
@ -157,7 +157,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
@ -165,7 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362 Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
@ -173,7 +173,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362 Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
@ -185,8 +185,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const DefaultPriority = 5 const DefaultPriority = 5
@ -41,8 +41,8 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer) request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer)
if err != nil { if err != nil {
return err return err
@ -67,12 +67,12 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
@ -88,7 +88,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
message += " with the following description: " + alert.GetDescription() message += " with the following description: " + alert.GetDescription()
} }
message += formattedConditionResults message += formattedConditionResults
title := "Gatus: " + endpoint.DisplayName() title := "Gatus: " + ep.DisplayName()
if provider.Title != "" { if provider.Title != "" {
title = provider.Title title = provider.Title
} }

View File

@ -6,7 +6,7 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_IsValid(t *testing.T) {
@ -49,7 +49,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
var ( var (
description = "custom-description" description = "custom-description"
//title = "custom-title" //title = "custom-title"
endpoint = "custom-endpoint" endpointName = "custom-endpoint"
) )
scenarios := []struct { scenarios := []struct {
Name string Name string
@ -63,30 +63,30 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "custom-title", Name: "custom-title",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}, Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpoint, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description),
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: endpoint}, &endpoint.Endpoint{Name: endpointName},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space // AlertProvider is the configuration necessary for sending an alert using JetBrains Space
@ -46,8 +46,8 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project) url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project)
request, err := http.NewRequest(http.MethodPost, url, buffer) request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil { if err != nil {
@ -103,9 +103,9 @@ type Icon struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{ body := Body{
Channel: "id:" + provider.getChannelIDForGroup(endpoint.Group), Channel: "id:" + provider.getChannelIDForGroup(ep.Group),
Content: Content{ Content: Content{
ClassName: "ChatMessage.Block", ClassName: "ChatMessage.Block",
Sections: []Section{{ Sections: []Section{{
@ -116,10 +116,10 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
} }
if resolved { if resolved {
body.Content.Style = "SUCCESS" 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", endpoint.DisplayName(), alert.SuccessThreshold) body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
body.Content.Style = "WARNING" 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", endpoint.DisplayName(), alert.FailureThreshold) body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
icon := "warning" icon := "warning"

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -120,10 +120,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -146,7 +146,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
ExpectedBody string ExpectedBody string
@ -154,7 +154,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
@ -162,7 +162,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered-with-group", Name: "triggered-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
@ -170,7 +170,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
@ -178,7 +178,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved-with-group", Name: "resolved-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
@ -189,8 +189,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -12,7 +12,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Matrix // AlertProvider is the configuration necessary for sending an alert using Matrix
@ -61,9 +61,9 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
config := provider.getConfigForGroup(endpoint.Group) config := provider.getConfigForGroup(ep.Group)
if config.ServerURL == "" { if config.ServerURL == "" {
config.ServerURL = defaultServerURL config.ServerURL = defaultServerURL
} }
@ -103,23 +103,23 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
MsgType: "m.text", MsgType: "m.text",
Format: "org.matrix.custom.html", Format: "org.matrix.custom.html",
Body: buildPlaintextMessageBody(endpoint, alert, result, resolved), Body: buildPlaintextMessageBody(ep, alert, result, resolved),
FormattedBody: buildHTMLMessageBody(endpoint, alert, result, resolved), FormattedBody: buildHTMLMessageBody(ep, alert, result, resolved),
}) })
return body return body
} }
// buildPlaintextMessageBody builds the message body in plaintext to include in request // buildPlaintextMessageBody builds the message body in plaintext to include in request
func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { func buildPlaintextMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
@ -139,12 +139,12 @@ func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, resu
} }
// buildHTMLMessageBody builds the message body in HTML to include in request // buildHTMLMessageBody builds the message body in HTML to include in request
func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -149,10 +149,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -197,10 +197,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -9,13 +9,16 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Mattermost // AlertProvider is the configuration necessary for sending an alert using Mattermost
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` WebhookURL string `yaml:"webhook-url"`
// Channel is the optional setting to override the default webhook's channel
Channel string `yaml:"channel,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target // ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"` ClientConfig *client.Config `yaml:"client,omitempty"`
@ -50,9 +53,9 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@ -70,6 +73,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
type Body struct { type Body struct {
Channel string `json:"channel,omitempty"` // Optional channel override
Text string `json:"text"` Text string `json:"text"`
Username string `json:"username"` Username string `json:"username"`
IconURL string `json:"icon_url"` IconURL string `json:"icon_url"`
@ -92,13 +96,13 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.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)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.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)
color = "#DD0000" color = "#DD0000"
} }
var formattedConditionResults string var formattedConditionResults string
@ -118,6 +122,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
body := Body{ body := Body{
Channel: provider.Channel,
Text: "", Text: "",
Username: "gatus", Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -120,10 +120,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -168,10 +168,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@ -33,8 +33,8 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms // Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@ -60,12 +60,12 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Originator: provider.Originator, Originator: provider.Originator,

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -83,10 +83,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -131,10 +131,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@ -25,6 +25,10 @@ type AlertProvider struct {
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to "" Token string `yaml:"token,omitempty"` // Defaults to ""
Email string `yaml:"email,omitempty"` // Defaults to ""
Click string `yaml:"click,omitempty"` // Defaults to ""
DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false
DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@ -46,8 +50,8 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer) request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
if err != nil { if err != nil {
return err return err
@ -56,6 +60,12 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
if len(provider.Token) > 0 { if len(provider.Token) > 0 {
request.Header.Set("Authorization", "Bearer "+provider.Token) request.Header.Set("Authorization", "Bearer "+provider.Token)
} }
if provider.DisableFirebase {
request.Header.Set("Firebase", "no")
}
if provider.DisableCache {
request.Header.Set("Cache", "no")
}
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@ -74,10 +84,12 @@ type Body struct {
Message string `json:"message"` Message string `json:"message"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Priority int `json:"priority"` Priority int `json:"priority"`
Email string `json:"email,omitempty"`
Click string `json:"click,omitempty"`
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, formattedConditionResults, tag string var message, formattedConditionResults, tag string
if resolved { if resolved {
tag = "white_check_mark" tag = "white_check_mark"
@ -101,10 +113,12 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
message += formattedConditionResults message += formattedConditionResults
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Topic: provider.Topic, Topic: provider.Topic,
Title: "Gatus: " + endpoint.DisplayName(), Title: "Gatus: " + ep.DisplayName(),
Message: message, Message: message,
Tags: []string{tag}, Tags: []string{tag},
Priority: provider.Priority, Priority: provider.Priority,
Email: provider.Email,
Click: provider.Click,
}) })
return body return body
} }

View File

@ -2,10 +2,13 @@ package ntfy
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
@ -88,14 +91,28 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: true, Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
}, },
{
Name: "triggered-email",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
},
{
Name: "resolved-email",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -112,3 +129,99 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_Send(t *testing.T) {
description := "description-1"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
ExpectedHeaders map[string]string
}{
{
Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
},
},
{
Name: "no firebase",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
},
},
{
Name: "no cache",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Cache": "no",
},
},
{
Name: "neither firebase & cache",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
"Cache": "no",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
// Start a local HTTP server
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// Test request parameters
for header, value := range scenario.ExpectedHeaders {
if value != req.Header.Get(header) {
t.Errorf("expected: %s, got: %s", value, req.Header.Get(header))
}
}
body, _ := io.ReadAll(req.Body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
// Send response to be tested
rw.Write([]byte(`OK`))
}))
// Close the server when test finishes
defer server.Close()
scenario.Provider.URL = server.URL
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if err != nil {
t.Error("Encountered an error on Send: ", err)
}
})
}
}

View File

@ -11,7 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@ -59,13 +59,13 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://docs.opsgenie.com/docs/alert-api // Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
err := provider.createAlert(endpoint, alert, result, resolved) err := provider.createAlert(ep, alert, result, resolved)
if err != nil { if err != nil {
return err return err
} }
if resolved { if resolved {
err = provider.closeAlert(endpoint, alert) err = provider.closeAlert(ep, alert)
if err != nil { if err != nil {
return err return err
} }
@ -75,20 +75,20 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey // The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = "" alert.ResolveKey = ""
} else { } else {
alert.ResolveKey = provider.alias(buildKey(endpoint)) alert.ResolveKey = provider.alias(buildKey(ep))
} }
} }
return nil return nil
} }
func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved) payload := provider.buildCreateRequestBody(ep, alert, result, resolved)
return provider.sendRequest(restAPI, http.MethodPost, payload) return provider.sendRequest(restAPI, http.MethodPost, payload)
} }
func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error { func (provider *AlertProvider) closeAlert(ep *endpoint.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(endpoint, alert) payload := provider.buildCloseRequestBody(ep, alert)
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias" url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias"
return provider.sendRequest(url, http.MethodPost, payload) return provider.sendRequest(url, http.MethodPost, payload)
} }
@ -115,17 +115,17 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return nil return nil
} }
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest { func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
var message, description string var message, description string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription()) message = fmt.Sprintf("%s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
} }
if endpoint.Group != "" { if ep.Group != "" {
message = fmt.Sprintf("[%s] %s", endpoint.Group, message) message = fmt.Sprintf("[%s] %s", ep.Group, message)
} }
var formattedConditionResults string var formattedConditionResults string
for _, conditionResult := range result.ConditionResults { for _, conditionResult := range result.ConditionResults {
@ -138,10 +138,10 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition) formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
} }
description = description + "\n" + formattedConditionResults description = description + "\n" + formattedConditionResults
key := buildKey(endpoint) key := buildKey(ep)
details := map[string]string{ details := map[string]string{
"endpoint:url": endpoint.URL, "endpoint:url": ep.URL,
"endpoint:group": endpoint.Group, "endpoint:group": ep.Group,
"result:hostname": result.Hostname, "result:hostname": result.Hostname,
"result:ip": result.IP, "result:ip": result.IP,
"result:dns_code": result.DNSRCode, "result:dns_code": result.DNSRCode,
@ -167,10 +167,10 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
} }
} }
func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) alertCloseRequest { func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, alert *alert.Alert) alertCloseRequest {
return alertCloseRequest{ return alertCloseRequest{
Source: buildKey(endpoint), Source: buildKey(ep),
Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()), Note: fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()),
} }
} }
@ -211,12 +211,12 @@ func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
func buildKey(endpoint *core.Endpoint) string { func buildKey(ep *endpoint.Endpoint) string {
name := toKebabCase(endpoint.Name) name := toKebabCase(ep.Name)
if endpoint.Group == "" { if ep.Group == "" {
return name return name
} }
return toKebabCase(endpoint.Group) + "-" + name return toKebabCase(ep.Group) + "-" + name
} }
func toKebabCase(val string) string { func toKebabCase(val string) string {

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -79,10 +79,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -106,8 +106,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name string Name string
Provider *AlertProvider Provider *AlertProvider
Alert *alert.Alert Alert *alert.Alert
Endpoint *core.Endpoint Endpoint *endpoint.Endpoint
Result *core.Result Result *endpoint.Result
Resolved bool Resolved bool
want alertCreateRequest want alertCreateRequest
}{ }{
@ -115,8 +115,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name: "missing all params (unresolved)", Name: "missing all params (unresolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &core.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &core.Result{}, Result: &endpoint.Result{},
Resolved: false, Resolved: false,
want: alertCreateRequest{ want: alertCreateRequest{
Message: " - ", Message: " - ",
@ -133,8 +133,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Name: "missing all params (resolved)", Name: "missing all params (resolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &core.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &core.Result{}, Result: &endpoint.Result{},
Resolved: true, Resolved: true,
want: alertCreateRequest{ want: alertCreateRequest{
Message: "RESOLVED: - ", Message: "RESOLVED: - ",
@ -154,11 +154,11 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Description: &description, Description: &description,
FailureThreshold: 3, FailureThreshold: 3,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "my super app", Name: "my super app",
}, },
Result: &core.Result{ Result: &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@ -194,11 +194,11 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Description: &description, Description: &description,
SuccessThreshold: 4, SuccessThreshold: 4,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "my mega app", Name: "my mega app",
}, },
Result: &core.Result{ Result: &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@ -226,17 +226,17 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
Description: &description, Description: &description,
FailureThreshold: 6, FailureThreshold: 6,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "my app", Name: "my app",
Group: "end game", Group: "end game",
URL: "https://my.go/app", URL: "https://my.go/app",
}, },
Result: &core.Result{ Result: &endpoint.Result{
HTTPStatus: 400, HTTPStatus: 400,
Hostname: "my.go", Hostname: "my.go",
Errors: []string{"error 01", "error 02"}, Errors: []string{"error 01", "error 02"},
Success: false, Success: false,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: false, Success: false,
@ -279,14 +279,14 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
Name string Name string
Provider *AlertProvider Provider *AlertProvider
Alert *alert.Alert Alert *alert.Alert
Endpoint *core.Endpoint Endpoint *endpoint.Endpoint
want alertCloseRequest want alertCloseRequest
}{ }{
{ {
Name: "Missing all values", Name: "Missing all values",
Provider: &AlertProvider{}, Provider: &AlertProvider{},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &core.Endpoint{}, Endpoint: &endpoint.Endpoint{},
want: alertCloseRequest{ want: alertCloseRequest{
Source: "", Source: "",
Note: "RESOLVED: - ", Note: "RESOLVED: - ",
@ -298,7 +298,7 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
}, },
Endpoint: &core.Endpoint{ Endpoint: &endpoint.Endpoint{
Name: "endpoint name", Name: "endpoint name",
}, },
want: alertCloseRequest{ want: alertCloseRequest{

View File

@ -10,7 +10,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@ -52,8 +52,8 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ // Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@ -101,19 +101,19 @@ type Payload struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, eventAction, resolveKey string var message, eventAction, resolveKey string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
eventAction = "resolve" eventAction = "resolve"
resolveKey = alert.ResolveKey resolveKey = alert.ResolveKey
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
eventAction = "trigger" eventAction = "trigger"
resolveKey = "" resolveKey = ""
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
RoutingKey: provider.getIntegrationKeyForGroup(endpoint.Group), RoutingKey: provider.getIntegrationKeyForGroup(ep.Group),
DedupKey: resolveKey, DedupKey: resolveKey,
EventAction: eventAction, EventAction: eventAction,
Payload: Payload{ Payload: Payload{

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -115,10 +115,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -161,7 +161,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&core.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &core.Result{}, scenario.Resolved) body := scenario.Provider.buildRequestBody(&endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }

View File

@ -6,6 +6,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/custom" "github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/googlechat"
@ -19,9 +20,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the interface that each provider should implement // AlertProvider is the interface that each provider should implement
@ -33,7 +36,7 @@ type AlertProvider interface {
GetDefaultAlert() *alert.Alert GetDefaultAlert() *alert.Alert
// Send an alert using the provider // Send an alert using the provider
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
} }
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline // ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
@ -66,6 +69,7 @@ var (
_ AlertProvider = (*email.AlertProvider)(nil) _ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil) _ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil) _ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil) _ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil) _ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil) _ AlertProvider = (*matrix.AlertProvider)(nil)
@ -77,6 +81,8 @@ var (
_ AlertProvider = (*pushover.AlertProvider)(nil) _ AlertProvider = (*pushover.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil) _ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*zulip.AlertProvider)(nil)
) )

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const ( const (
@ -52,8 +52,8 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api // Reference doc for pushover: https://pushover.net/api
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@ -81,12 +81,12 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Token: provider.ApplicationToken, Token: provider.ApplicationToken,

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -95,10 +95,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -150,10 +150,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Slack // AlertProvider is the configuration necessary for sending an alert using Slack
@ -42,9 +42,9 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
@ -81,13 +81,13 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.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)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.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)
color = "#DD0000" color = "#DD0000"
} }
var formattedConditionResults string var formattedConditionResults string

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -142,7 +142,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Endpoint core.Endpoint Endpoint endpoint.Endpoint
Alert alert.Alert Alert alert.Alert
NoConditions bool NoConditions bool
Resolved bool Resolved bool
@ -151,7 +151,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@ -159,7 +159,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered-with-group", Name: "triggered-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@ -168,7 +168,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Name: "triggered-with-no-conditions", Name: "triggered-with-no-conditions",
NoConditions: true, NoConditions: true,
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\"}]}", ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\"}]}",
@ -176,7 +176,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@ -184,7 +184,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved-with-group", Name: "resolved-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{},
Endpoint: core.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
@ -192,9 +192,9 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
} }
@ -202,7 +202,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: conditionResults, ConditionResults: conditionResults,
}, },
scenario.Resolved, scenario.Resolved,

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Teams // AlertProvider is the configuration necessary for sending an alert using Teams
@ -19,8 +19,14 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` Overrides []Override `yaml:"overrides,omitempty"`
// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
} }
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
@ -44,14 +50,14 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@ -78,13 +84,13 @@ type Section struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.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)
color = "#36A64F" color = "#36A64F"
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.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)
color = "#DD0000" color = "#DD0000"
} }
var formattedConditionResults string var formattedConditionResults string
@ -105,9 +111,12 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
Type: "MessageCard", Type: "MessageCard",
Context: "http://schema.org/extensions", Context: "http://schema.org/extensions",
ThemeColor: color, ThemeColor: color,
Title: "&#x1F6A8; Gatus", Title: provider.Title,
Text: message + description, Text: message + description,
} }
if len(body.Title) == 0 {
body.Title = "&#x1F6A8; Gatus"
}
if len(formattedConditionResults) > 0 { if len(formattedConditionResults) > 0 {
body.Sections = append(body.Sections, Section{ body.Sections = append(body.Sections, Section{
ActivityTitle: "Condition results", ActivityTitle: "Condition results",

View File

@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -172,17 +172,17 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved, scenario.Resolved,
) )
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {

View File

@ -0,0 +1,182 @@
package teamsworkflows
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"`
// 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"`
// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.WebhookURL) > 0
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// AdaptiveCardBody represents the structure of an Adaptive Card
type AdaptiveCardBody struct {
Type string `json:"type"`
Version string `json:"version"`
Body []CardBody `json:"body"`
}
// CardBody represents the body of the Adaptive Card
type CardBody struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Wrap bool `json:"wrap"`
Separator bool `json:"separator,omitempty"`
Size string `json:"size,omitempty"`
Weight string `json:"weight,omitempty"`
Items []CardBody `json:"items,omitempty"`
Facts []Fact `json:"facts,omitempty"`
FactSet *FactSetBody `json:"factSet,omitempty"`
}
// FactSetBody represents the FactSet in the Adaptive Card
type FactSetBody struct {
Type string `json:"type"`
Facts []Fact `json:"facts"`
}
// Fact represents an individual fact in the FactSet
type Fact struct {
Title string `json:"title"`
Value string `json:"value"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string
if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row.", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row.", ep.DisplayName(), alert.FailureThreshold)
}
// Configure default title if it's not provided
title := "&#x26D1; Gatus"
if provider.Title != "" {
title = provider.Title
}
// Build the facts from the condition results
var facts []Fact
for _, conditionResult := range result.ConditionResults {
var key string
if conditionResult.Success {
key = "&#x2705;"
} else {
key = "&#x274C;"
}
facts = append(facts, Fact{
Title: key,
Value: conditionResult.Condition,
})
}
cardContent := AdaptiveCardBody{
Type: "AdaptiveCard",
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
Body: []CardBody{
{
Type: "TextBlock",
Text: title,
Size: "Medium",
Weight: "Bolder",
},
{
Type: "TextBlock",
Text: message,
Wrap: true,
},
{
Type: "FactSet",
Facts: facts,
},
},
}
attachment := map[string]interface{}{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": cardContent,
}
payload := map[string]interface{}{
"type": "message",
"attachments": []interface{}{attachment},
}
bodyAsJSON, _ := json.Marshal(payload)
return bodyAsJSON
}
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@ -0,0 +1,269 @@
package teamsworkflows
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{WebhookURL: "http://example.com"}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
WebhookURL: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
WebhookURL: "http://example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x274C;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x274C;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x2705;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x2705;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "",
ExpectedOutput: "http://example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
WebhookURL: "http://example.com",
Overrides: []Override{
{
Group: "group",
WebhookURL: "http://example01.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "http://example01.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}

View File

@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
const defaultAPIURL = "https://api.telegram.org" const defaultAPIURL = "https://api.telegram.org"
@ -25,6 +25,16 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Overrid that may be prioritized over the default configuration
Overrides []*Override `yaml:"overrides,omitempty"`
}
// Override is a configuration that may be prioritized over the default configuration
type Override struct {
group string `yaml:"group"`
token string `yaml:"token"`
id string `yaml:"id"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
@ -32,17 +42,29 @@ func (provider *AlertProvider) IsValid() bool {
if provider.ClientConfig == nil { if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig() provider.ClientConfig = client.GetDefaultConfig()
} }
registerGroups := make(map[string]bool)
for _, override := range provider.Overrides {
if len(override.group) == 0 {
return false
}
if _, ok := registerGroups[override.group]; ok {
return false
}
registerGroups[override.group] = true
}
return len(provider.Token) > 0 && len(provider.ID) > 0 return len(provider.Token) > 0 && len(provider.ID) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
apiURL := provider.APIURL apiURL := provider.APIURL
if apiURL == "" { if apiURL == "" {
apiURL = defaultAPIURL apiURL = defaultAPIURL
} }
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer) request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.getTokenForGroup(ep.Group)), buffer)
if err != nil { if err != nil {
return err return err
} }
@ -59,6 +81,15 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
return err return err
} }
func (provider *AlertProvider) getTokenForGroup(group string) string {
for _, override := range provider.Overrides {
if override.group == group && len(override.token) > 0 {
return override.token
}
}
return provider.Token
}
type Body struct { type Body struct {
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Text string `json:"text"` Text string `json:"text"`
@ -66,12 +97,12 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold) message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", ep.DisplayName(), alert.FailureThreshold)
} }
var formattedConditionResults string var formattedConditionResults string
if len(result.ConditionResults) > 0 { if len(result.ConditionResults) > 0 {
@ -93,13 +124,22 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults) text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
} }
bodyAsJSON, _ := json.Marshal(Body{ bodyAsJSON, _ := json.Marshal(Body{
ChatID: provider.ID, ChatID: provider.getIDForGroup(ep.Group),
Text: text, Text: text,
ParseMode: "MARKDOWN", ParseMode: "MARKDOWN",
}) })
return bodyAsJSON return bodyAsJSON
} }
func (provider *AlertProvider) getIDForGroup(group string) string {
for _, override := range provider.Overrides {
if override.group == group && len(override.id) > 0 {
return override.id
}
}
return provider.ID
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert

View File

@ -7,11 +7,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""} invalidProvider := AlertProvider{Token: "", ID: ""}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
@ -32,6 +32,69 @@ func TestAlertProvider_IsValid(t *testing.T) {
}) })
} }
func TestAlertProvider_IsValidWithOverrides(t *testing.T) {
t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{token: "token", id: "id"}}}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
})
t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group1", token: "token", id: "id"}, {group: "group1", id: "id2"}}}
if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "token", id: "id"}}}
if validProvider.ClientConfig != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
})
}
func TestAlertProvider_getTokenAndIDForGroup(t *testing.T) {
t.Run("get-token-with-override", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken", id: "overrideID"}}}
token := provider.getTokenForGroup("group")
if token != "overrideToken" {
t.Error("token should have been 'overrideToken'")
}
id := provider.getIDForGroup("group")
if id != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", id: "overrideID"}}}
token := provider.getTokenForGroup("group")
if token != provider.Token {
t.Error("token should have been the default token")
}
id := provider.getIDForGroup("group")
if id != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken"}}}
token := provider.getTokenForGroup("group")
if token != "overrideToken" {
t.Error("token should have been 'overrideToken'")
}
id := provider.getIDForGroup("group")
if id != provider.ID {
t.Error("id should have been the default id")
}
})
}
func TestAlertProvider_Send(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil) defer client.InjectHTTPClient(nil)
firstDescription := "description-1" firstDescription := "description-1"
@ -89,10 +152,10 @@ func TestAlertProvider_Send(t *testing.T) {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err := scenario.Provider.Send(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },
@ -145,17 +208,17 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*core.ConditionResult var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions { if !scenario.NoConditions {
conditionResults = []*core.ConditionResult{ conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved, scenario.Resolved,
) )
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {

View File

@ -10,7 +10,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the configuration necessary for sending an alert using Twilio // AlertProvider is the configuration necessary for sending an alert using Twilio
@ -30,8 +30,8 @@ func (provider *AlertProvider) IsValid() bool {
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer) request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
if err != nil { if err != nil {
return err return err
@ -51,12 +51,12 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
return url.Values{ return url.Values{
"To": {provider.To}, "To": {provider.To},

View File

@ -4,7 +4,7 @@ import (
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestTwilioAlertProvider_IsValid(t *testing.T) { func TestTwilioAlertProvider_IsValid(t *testing.T) {
@ -51,10 +51,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&core.Result{ &endpoint.Result{
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved},
}, },

View File

@ -0,0 +1,132 @@
package zulip
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
)
type Config struct {
// BotEmail is the email of the bot user
BotEmail string `yaml:"bot-email"`
// BotAPIKey is the API key of the bot user
BotAPIKey string `yaml:"bot-api-key"`
// Domain is the domain of the Zulip server
Domain string `yaml:"domain"`
// ChannelID is the ID of the channel to send the message to
ChannelID string `yaml:"channel-id"`
}
// AlertProvider is the configuration necessary for sending an alert using Zulip
type AlertProvider struct {
Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Config
Group string `yaml:"group"`
}
func (provider *AlertProvider) validateConfig(conf *Config) bool {
return len(conf.BotEmail) > 0 && len(conf.BotAPIKey) > 0 && len(conf.Domain) > 0 && len(conf.ChannelID) > 0
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
isAlreadyRegistered := registeredGroups[override.Group]
if isAlreadyRegistered || override.Group == "" || !provider.validateConfig(&override.Config) {
return false
}
registeredGroups[override.Group] = true
}
}
return provider.validateConfig(&provider.Config)
}
// getChannelIdForGroup returns the channel ID for the provided group
func (provider *AlertProvider) getChannelIdForGroup(group string) string {
for _, override := range provider.Overrides {
if override.Group == group {
return override.ChannelID
}
}
return provider.ChannelID
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string
if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += "\n> " + alertDescription + "\n"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":check:"
} else {
prefix = ":cross_mark:"
}
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
}
postData := map[string]string{
"type": "channel",
"to": provider.getChannelIdForGroup(ep.Group),
"topic": "Gatus",
"content": message,
}
bodyParams := url.Values{}
for field, value := range postData {
bodyParams.Add(field, value)
}
return bodyParams.Encode()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBufferString(provider.buildRequestBody(ep, alert, result, resolved))
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", provider.Domain)
request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)
if err != nil {
return err
}
request.SetBasicAuth(provider.BotEmail, provider.BotAPIKey)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Gatus")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@ -0,0 +1,488 @@
package zulip
import (
"fmt"
"net/http"
"net/url"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_IsValid(t *testing.T) {
testCase := []struct {
name string
alertProvider AlertProvider
expected bool
}{
{
name: "Empty provider",
alertProvider: AlertProvider{},
expected: false,
},
{
name: "Empty channel id",
alertProvider: AlertProvider{
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
expected: false,
},
{
name: "Empty domain",
alertProvider: AlertProvider{
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
expected: false,
},
{
name: "Empty bot api key",
alertProvider: AlertProvider{
Config: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
expected: false,
},
{
name: "Empty bot email",
alertProvider: AlertProvider{
Config: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
expected: false,
},
{
name: "Valid provider",
alertProvider: AlertProvider{
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
expected: true,
},
}
for _, tc := range testCase {
t.Run(tc.name, func(t *testing.T) {
if tc.alertProvider.IsValid() != tc.expected {
t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected)
}
})
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
validConfig := Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
}
testCase := []struct {
name string
alertProvider AlertProvider
expected bool
}{
{
name: "Empty group",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Config: validConfig,
Group: "",
},
},
},
expected: false,
},
{
name: "Empty override config",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
},
},
},
expected: false,
},
{
name: "Empty channel id",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
},
},
expected: false,
},
{
name: "Empty domain",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
},
},
expected: false,
},
{
name: "Empty bot api key",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
},
},
expected: false,
},
{
name: "Empty bot email",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
},
},
expected: false,
},
{
name: "Valid provider",
alertProvider: AlertProvider{
Config: validConfig,
Overrides: []Override{
{
Group: "something",
Config: validConfig,
},
},
},
expected: true,
},
}
for _, tc := range testCase {
t.Run(tc.name, func(t *testing.T) {
if tc.alertProvider.IsValid() != tc.expected {
t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected)
}
})
}
}
func TestAlertProvider_GetChannelIdForGroup(t *testing.T) {
provider := AlertProvider{
Config: Config{
ChannelID: "default",
},
Overrides: []Override{
{
Group: "group1",
Config: Config{ChannelID: "group1"},
},
{
Group: "group2",
Config: Config{ChannelID: "group2"},
},
},
}
if provider.getChannelIdForGroup("") != "default" {
t.Error("Expected default channel ID")
}
if provider.getChannelIdForGroup("group2") != "group2" {
t.Error("Expected group2 channel ID")
}
}
func TestAlertProvider_BuildRequestBody(t *testing.T) {
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "domain",
ChannelID: "channel-id",
}
alertDesc := "Description"
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
Description: &alertDesc,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
hasConditions bool
expectedBody url.Values
}{
{
name: "Resolved alert with no conditions",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Resolved alert with conditions",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row
> Description
:check: - ` + "`[CONNECTED] == true`" + `
:check: - ` + "`[STATUS] == 200`" + `
:check: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with no conditions",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with conditions",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row
> Description
:cross_mark: - ` + "`[CONNECTED] == true`" + `
:cross_mark: - ` + "`[STATUS] == 200`" + `
:cross_mark: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if tc.hasConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
{Condition: "[BODY] != \"\"", Success: tc.resolved},
}
}
body := tc.provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&tc.alert,
&endpoint.Result{
ConditionResults: conditionResults,
},
tc.resolved,
)
valuesResult, err := url.ParseQuery(body)
if err != nil {
t.Error(err)
}
if fmt.Sprintf("%v", valuesResult) != fmt.Sprintf("%v", tc.expectedBody) {
t.Errorf("Expected body:\n%v\ngot:\n%v", tc.expectedBody, valuesResult)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
validateRequest := func(req *http.Request) {
if req.URL.String() != "https://custom-domain/api/v1/messages" {
t.Errorf("expected url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
}
if req.Method != http.MethodPost {
t.Errorf("expected POST request, got %s", req.Method)
}
if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("expected Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type"))
}
if req.Header.Get("User-Agent") != "Gatus" {
t.Errorf("expected User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
}
}
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "custom-domain",
ChannelID: "channel-id",
}
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "resolved",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "resolved error",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
{
name: "triggered",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "triggered error",
provider: AlertProvider{
Config: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
err := tc.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&tc.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
},
},
tc.resolved,
)
if tc.expectedError && err == nil {
t.Error("expected error, got none")
}
if !tc.expectedError && err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
}
}

View File

@ -9,7 +9,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/common/paging"
@ -37,11 +37,13 @@ var (
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
// //
// Valid values for :duration -> 7d, 24h, 1h // Valid values for :duration -> 30d, 7d, 24h, 1h
func UptimeBadge(c *fiber.Ctx) error { func UptimeBadge(c *fiber.Ctx) error {
duration := c.Params("duration") duration := c.Params("duration")
var from time.Time var from time.Time
switch duration { switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d": case "7d":
from = time.Now().Add(-7 * 24 * time.Hour) from = time.Now().Add(-7 * 24 * time.Hour)
case "24h": case "24h":
@ -49,7 +51,7 @@ func UptimeBadge(c *fiber.Ctx) error {
case "1h": case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default: default:
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h") return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
} }
key := c.Params("key") key := c.Params("key")
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now()) uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
@ -69,12 +71,14 @@ func UptimeBadge(c *fiber.Ctx) error {
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
// //
// Valid values for :duration -> 7d, 24h, 1h // Valid values for :duration -> 30d, 7d, 24h, 1h
func ResponseTimeBadge(cfg *config.Config) fiber.Handler { func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
duration := c.Params("duration") duration := c.Params("duration")
var from time.Time var from time.Time
switch duration { switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d": case "7d":
from = time.Now().Add(-7 * 24 * time.Hour) from = time.Now().Add(-7 * 24 * time.Hour)
case "24h": case "24h":
@ -82,7 +86,7 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
case "1h": case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default: default:
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h") return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
} }
key := c.Params("key") key := c.Params("key")
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now()) averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
@ -161,6 +165,8 @@ func HealthBadgeShields(c *fiber.Ctx) error {
func generateUptimeBadgeSVG(duration string, uptime float64) []byte { func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int var labelWidth, valueWidth, valueWidthAdjustment int
switch duration { switch duration {
case "30d":
labelWidth = 70
case "7d": case "7d":
labelWidth = 65 labelWidth = 65
case "24h": case "24h":
@ -227,6 +233,8 @@ func getBadgeColorFromUptime(uptime float64) string {
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte { func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
var labelWidth, valueWidth int var labelWidth, valueWidth int
switch duration { switch duration {
case "30d":
labelWidth = 110
case "7d": case "7d":
labelWidth = 105 labelWidth = 105
case "24h": case "24h":

View File

@ -8,8 +8,8 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@ -19,7 +19,7 @@ func TestBadge(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@ -34,8 +34,8 @@ func TestBadge(t *testing.T) {
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig() cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig() cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
@ -218,30 +218,30 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
defer cache.Clear() defer cache.Clear()
var ( var (
firstCondition = core.Condition("[STATUS] == 200") firstCondition = endpoint.Condition("[STATUS] == 200")
secondCondition = core.Condition("[RESPONSE_TIME] < 500") secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
) )
firstTestEndpoint := core.Endpoint{ firstTestEndpoint := endpoint.Endpoint{
Name: "a", Name: "a",
URL: "https://example.org/what/ever", URL: "https://example.org/what/ever",
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
UIConfig: ui.GetDefaultConfig(), UIConfig: ui.GetDefaultConfig(),
} }
secondTestEndpoint := core.Endpoint{ secondTestEndpoint := endpoint.Endpoint{
Name: "b", Name: "b",
URL: "https://example.org/what/ever", URL: "https://example.org/what/ever",
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
@ -255,10 +255,10 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
} }
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint}, Endpoints: []*endpoint.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
} }
testSuccessfulResult := core.Result{ testSuccessfulResult := endpoint.Result{
Hostname: "example.org", Hostname: "example.org",
IP: "127.0.0.1", IP: "127.0.0.1",
HTTPStatus: 200, HTTPStatus: 200,
@ -268,7 +268,7 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
Timestamp: time.Now(), Timestamp: time.Now(),
Duration: 150 * time.Millisecond, Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour, CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,

View File

@ -32,14 +32,18 @@ var (
func ResponseTimeChart(c *fiber.Ctx) error { func ResponseTimeChart(c *fiber.Ctx) error {
duration := c.Params("duration") duration := c.Params("duration")
chartTimestampFormatter := chart.TimeValueFormatterWithFormat(timeFormat)
var from time.Time var from time.Time
switch duration { switch duration {
case "30d":
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
chartTimestampFormatter = chart.TimeDateValueFormatter
case "7d": case "7d":
from = time.Now().Truncate(time.Hour).Add(-24 * 7 * time.Hour) from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
case "24h": case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour) from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default: default:
return c.Status(400).SendString("Durations supported: 7d, 24h") return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
} }
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now()) hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
if err != nil { if err != nil {
@ -88,7 +92,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
Width: 1280, Width: 1280,
Height: 300, Height: 300,
XAxis: chart.XAxis{ XAxis: chart.XAxis{
ValueFormatter: chart.TimeValueFormatterWithFormat(timeFormat), ValueFormatter: chartTimestampFormatter,
GridMajorStyle: gridStyle, GridMajorStyle: gridStyle,
GridMinorStyle: gridStyle, GridMinorStyle: gridStyle,
Style: axisStyle, Style: axisStyle,

View File

@ -7,7 +7,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@ -17,7 +17,7 @@ func TestResponseTimeChart(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@ -28,8 +28,8 @@ func TestResponseTimeChart(t *testing.T) {
}, },
}, },
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
@ -49,6 +49,11 @@ func TestResponseTimeChart(t *testing.T) {
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg", Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK, ExpectedCode: http.StatusOK,
}, },
{
Name: "chart-response-time-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d/chart.svg",
ExpectedCode: http.StatusOK,
},
{ {
Name: "chart-response-time-with-invalid-duration", Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg", Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",

View File

@ -4,13 +4,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/remote" "github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/common/paging"
@ -51,25 +50,19 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
} }
} }
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) { func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*endpoint.Status, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 { if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil return nil, nil
} }
var endpointStatusesFromAllRemotes []*core.EndpointStatus var endpointStatusesFromAllRemotes []*endpoint.Status
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig) httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
for _, instance := range remoteConfig.Instances { for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL) response, err := httpClient.Get(instance.URL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
body, err := io.ReadAll(response.Body) var endpointStatuses []*endpoint.Status
if err != nil { if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {
_ = response.Body.Close()
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
var endpointStatuses []*core.EndpointStatus
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
_ = response.Body.Close() _ = response.Body.Close()
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error()) log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue continue
@ -83,7 +76,7 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor
return endpointStatusesFromAllRemotes, nil return endpointStatusesFromAllRemotes, nil
} }
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name // EndpointStatus retrieves a single endpoint.Status by group and endpoint name
func EndpointStatus(c *fiber.Ctx) error { func EndpointStatus(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c) page, pageSize := extractPageAndPageSizeFromRequest(c)
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents)) endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))

View File

@ -8,7 +8,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@ -16,19 +16,19 @@ import (
var ( var (
timestamp = time.Now() timestamp = time.Now()
testEndpoint = core.Endpoint{ testEndpoint = endpoint.Endpoint{
Name: "name", Name: "name",
Group: "group", Group: "group",
URL: "https://example.org/what/ever", URL: "https://example.org/what/ever",
Method: "GET", Method: "GET",
Body: "body", Body: "body",
Interval: 30 * time.Second, Interval: 30 * time.Second,
Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")}, Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500"), endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
Alerts: nil, Alerts: nil,
NumberOfFailuresInARow: 0, NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0, NumberOfSuccessesInARow: 0,
} }
testSuccessfulResult = core.Result{ testSuccessfulResult = endpoint.Result{
Hostname: "example.org", Hostname: "example.org",
IP: "127.0.0.1", IP: "127.0.0.1",
HTTPStatus: 200, HTTPStatus: 200,
@ -38,7 +38,7 @@ var (
Timestamp: timestamp, Timestamp: timestamp,
Duration: 150 * time.Millisecond, Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour, CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@ -53,7 +53,7 @@ var (
}, },
}, },
} }
testUnsuccessfulResult = core.Result{ testUnsuccessfulResult = endpoint.Result{
Hostname: "example.org", Hostname: "example.org",
IP: "127.0.0.1", IP: "127.0.0.1",
HTTPStatus: 200, HTTPStatus: 200,
@ -63,7 +63,7 @@ var (
Timestamp: timestamp, Timestamp: timestamp,
Duration: 750 * time.Millisecond, Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour, CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
{ {
Condition: "[STATUS] == 200", Condition: "[STATUS] == 200",
Success: true, Success: true,
@ -85,7 +85,7 @@ func TestEndpointStatus(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@ -96,8 +96,8 @@ func TestEndpointStatus(t *testing.T) {
}, },
}, },
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {

View File

@ -7,7 +7,7 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
@ -41,11 +41,14 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
return c.Status(401).SendString("invalid token") return c.Status(401).SendString("invalid token")
} }
// Persist the result in the storage // Persist the result in the storage
result := &core.Result{ result := &endpoint.Result{
Timestamp: time.Now(), Timestamp: time.Now(),
Success: c.QueryBool("success"), Success: c.QueryBool("success"),
Errors: []string{}, Errors: []string{},
} }
if !result.Success && c.Query("error") != "" {
result.Errors = append(result.Errors, c.Query("error"))
}
convertedEndpoint := externalEndpoint.ToEndpoint() convertedEndpoint := externalEndpoint.ToEndpoint()
if err := store.Get().Insert(convertedEndpoint, result); err != nil { if err := store.Get().Insert(convertedEndpoint, result); err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {

View File

@ -9,8 +9,8 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/maintenance" "github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/common/paging"
) )
@ -22,7 +22,7 @@ func TestCreateExternalEndpointResult(t *testing.T) {
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Discord: &discord.AlertProvider{}, Discord: &discord.AlertProvider{},
}, },
ExternalEndpoints: []*core.ExternalEndpoint{ ExternalEndpoints: []*endpoint.ExternalEndpoint{
{ {
Name: "n", Name: "n",
Group: "g", Group: "g",
@ -64,12 +64,24 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token", AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 404, ExpectedCode: 404,
}, },
{
Name: "bad-success-value",
Path: "/api/v1/endpoints/g_n/external?success=invalid",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 400,
},
{ {
Name: "good-token-success-true", Name: "good-token-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true", Path: "/api/v1/endpoints/g_n/external?success=true",
AuthorizationHeaderBearerToken: "Bearer token", AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200, ExpectedCode: 200,
}, },
{
Name: "good-token-success-true-with-ignored-error-because-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{ {
Name: "good-token-success-false", Name: "good-token-success-false",
Path: "/api/v1/endpoints/g_n/external?success=false", Path: "/api/v1/endpoints/g_n/external?success=false",
@ -82,6 +94,12 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token", AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200, ExpectedCode: 200,
}, },
{
Name: "good-token-success-false-with-error",
Path: "/api/v1/endpoints/g_n/external?success=false&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
@ -108,21 +126,33 @@ func TestCreateExternalEndpointResult(t *testing.T) {
if endpointStatus.Key != "g_n" { if endpointStatus.Key != "g_n" {
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key) t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
} }
if len(endpointStatus.Results) != 3 { if len(endpointStatus.Results) != 5 {
t.Errorf("expected 3 results but got %d", len(endpointStatus.Results)) t.Errorf("expected 3 results but got %d", len(endpointStatus.Results))
} }
if !endpointStatus.Results[0].Success { if !endpointStatus.Results[0].Success {
t.Errorf("expected first result to be successful") t.Errorf("expected first result to be successful")
} }
if endpointStatus.Results[1].Success { if !endpointStatus.Results[1].Success {
t.Errorf("expected second result to be unsuccessful") t.Errorf("expected second result to be successful")
}
if len(endpointStatus.Results[1].Errors) > 0 {
t.Errorf("expected second result to have no errors")
} }
if endpointStatus.Results[2].Success { if endpointStatus.Results[2].Success {
t.Errorf("expected third result to be unsuccessful") t.Errorf("expected third result to be unsuccessful")
} }
if endpointStatus.Results[3].Success {
t.Errorf("expected fourth result to be unsuccessful")
}
if endpointStatus.Results[4].Success {
t.Errorf("expected fifth result to be unsuccessful")
}
if len(endpointStatus.Results[4].Errors) == 0 || endpointStatus.Results[4].Errors[0] != "failed" {
t.Errorf("expected fifth result to have errors: failed")
}
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n") externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
if externalEndpointFromConfig.NumberOfFailuresInARow != 2 { if externalEndpointFromConfig.NumberOfFailuresInARow != 3 {
t.Errorf("expected 2 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow) t.Errorf("expected 3 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow)
} }
if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 { if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 {
t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow) t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow)

View File

@ -9,8 +9,8 @@ import (
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/ui" "github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@ -20,7 +20,7 @@ func TestSinglePageApplication(t *testing.T) {
defer cache.Clear() defer cache.Clear()
cfg := &config.Config{ cfg := &config.Config{
Metrics: true, Metrics: true,
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "frontend", Name: "frontend",
Group: "core", Group: "core",
@ -34,8 +34,8 @@ func TestSinglePageApplication(t *testing.T) {
Title: "example-title", Title: "example-title",
}, },
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg) api := New(cfg)
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {

View File

@ -16,11 +16,16 @@ import (
"github.com/TwiN/gocache/v2" "github.com/TwiN/gocache/v2"
"github.com/TwiN/whois" "github.com/TwiN/whois"
"github.com/ishidawataru/sctp" "github.com/ishidawataru/sctp"
"github.com/miekg/dns"
ping "github.com/prometheus-community/pro-bing" ping "github.com/prometheus-community/pro-bing"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/net/websocket" "golang.org/x/net/websocket"
) )
const (
dnsPort = 53
)
var ( var (
// injectedHTTPClient is used for testing purposes // injectedHTTPClient is used for testing purposes
injectedHTTPClient *http.Client injectedHTTPClient *http.Client
@ -291,6 +296,49 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
return true, msg[:n], nil return true, msg[:n], nil
} }
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
if !strings.Contains(url, ":") {
url = fmt.Sprintf("%s:%d", url, dnsPort)
}
queryTypeAsUint16 := dns.StringToType[queryType]
c := new(dns.Client)
m := new(dns.Msg)
m.SetQuestion(queryName, queryTypeAsUint16)
r, _, err := c.Exchange(m, url)
if err != nil {
return false, "", nil, err
}
connected = true
dnsRcode = dns.RcodeToString[r.Rcode]
for _, rr := range r.Answer {
switch rr.Header().Rrtype {
case dns.TypeA:
if a, ok := rr.(*dns.A); ok {
body = []byte(a.A.String())
}
case dns.TypeAAAA:
if aaaa, ok := rr.(*dns.AAAA); ok {
body = []byte(aaaa.AAAA.String())
}
case dns.TypeCNAME:
if cname, ok := rr.(*dns.CNAME); ok {
body = []byte(cname.Target)
}
case dns.TypeMX:
if mx, ok := rr.(*dns.MX); ok {
body = []byte(mx.Mx)
}
case dns.TypeNS:
if ns, ok := rr.(*dns.NS); ok {
body = []byte(ns.Ns)
}
default:
body = []byte("query type is not supported yet")
}
}
return connected, dnsRcode, body, nil
}
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes // InjectHTTPClient is used to inject a custom HTTP client for testing purposes
func InjectHTTPClient(httpClient *http.Client) { func InjectHTTPClient(httpClient *http.Client) {
injectedHTTPClient = httpClient injectedHTTPClient = httpClient

View File

@ -8,6 +8,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/pattern"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -334,3 +336,97 @@ func TestTlsRenegotiation(t *testing.T) {
}) })
} }
} }
func TestQueryDNS(t *testing.T) {
tests := []struct {
name string
inputDNS dns.Config
inputURL string
expectedDNSCode string
expectedBody string
isErrExpected bool
}{
{
name: "test Config with type A",
inputDNS: dns.Config{
QueryType: "A",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "93.184.215.14",
},
{
name: "test Config with type AAAA",
inputDNS: dns.Config{
QueryType: "AAAA",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "2606:2800:21f:cb07:6820:80da:af6b:8b2c",
},
{
name: "test Config with type CNAME",
inputDNS: dns.Config{
QueryType: "CNAME",
QueryName: "en.wikipedia.org.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "dyna.wikimedia.org.",
},
{
name: "test Config with type MX",
inputDNS: dns.Config{
QueryType: "MX",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: ".",
},
{
name: "test Config with type NS",
inputDNS: dns.Config{
QueryType: "NS",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "*.iana-servers.net.",
},
{
name: "test Config with fake type and retrieve error",
inputDNS: dns.Config{
QueryType: "B",
QueryName: "example",
},
inputURL: "8.8.8.8",
isErrExpected: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, dnsRCode, body, err := QueryDNS(test.inputDNS.QueryType, test.inputDNS.QueryName, test.inputURL)
if test.isErrExpected && err == nil {
t.Errorf("there should be an error")
}
if dnsRCode != test.expectedDNSCode {
t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, dnsRCode)
}
if test.inputDNS.QueryType == "NS" {
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
if !pattern.Match(test.expectedBody, string(body)) {
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
}
} else {
if string(body) != test.expectedBody {
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
}
}
})
time.Sleep(5 * time.Millisecond)
}
}

View File

@ -15,14 +15,13 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/config/connectivity" "github.com/TwiN/gatus/v5/config/connectivity"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/maintenance" "github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/config/remote" "github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/config/ui" "github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web" "github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/security" "github.com/TwiN/gatus/v5/security"
"github.com/TwiN/gatus/v5/storage" "github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/util"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -74,10 +73,10 @@ type Config struct {
Alerting *alerting.Config `yaml:"alerting,omitempty"` Alerting *alerting.Config `yaml:"alerting,omitempty"`
// Endpoints is the list of endpoints to monitor // Endpoints is the list of endpoints to monitor
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"` Endpoints []*endpoint.Endpoint `yaml:"endpoints,omitempty"`
// ExternalEndpoints is the list of all external endpoints // ExternalEndpoints is the list of all external endpoints
ExternalEndpoints []*core.ExternalEndpoint `yaml:"external-endpoints,omitempty"` ExternalEndpoints []*endpoint.ExternalEndpoint `yaml:"external-endpoints,omitempty"`
// Storage is the configuration for how the data is stored // Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage,omitempty"` Storage *storage.Config `yaml:"storage,omitempty"`
@ -102,20 +101,20 @@ type Config struct {
lastFileModTime time.Time // last modification time lastFileModTime time.Time // last modification time
} }
func (config *Config) GetEndpointByKey(key string) *core.Endpoint { func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
for i := 0; i < len(config.Endpoints); i++ { for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i] ep := config.Endpoints[i]
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key { if ep.Key() == key {
return ep return ep
} }
} }
return nil return nil
} }
func (config *Config) GetExternalEndpointByKey(key string) *core.ExternalEndpoint { func (config *Config) GetExternalEndpointByKey(key string) *endpoint.ExternalEndpoint {
for i := 0; i < len(config.ExternalEndpoints); i++ { for i := 0; i < len(config.ExternalEndpoints); i++ {
ee := config.ExternalEndpoints[i] ee := config.ExternalEndpoints[i]
if util.ConvertGroupAndEndpointNameToKey(ee.Group, ee.Name) == key { if ee.Key() == key {
return ee return ee
} }
} }
@ -189,7 +188,7 @@ func LoadConfiguration(configPath string) (*Config, error) {
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err) return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
} }
} else { } else {
log.Printf("[config.LoadConfiguration] Reading configuration from configFile=%s", configPath) log.Printf("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath)
if data, err := os.ReadFile(usedConfigPath); err != nil { if data, err := os.ReadFile(usedConfigPath); err != nil {
return nil, err return nil, err
} else { } else {
@ -246,16 +245,13 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 { if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
err = ErrNoEndpointInConfig err = ErrNoEndpointInConfig
} else { } else {
validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug) validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints, config.Debug)
if err := validateSecurityConfig(config); err != nil { if err := validateSecurityConfig(config); err != nil {
return nil, err return nil, err
} }
if err := validateEndpointsConfig(config); err != nil { if err := validateEndpointsConfig(config); err != nil {
return nil, err return nil, err
} }
if err := validateExternalEndpointsConfig(config); err != nil {
return nil, err
}
if err := validateWebConfig(config); err != nil { if err := validateWebConfig(config); err != nil {
return nil, err return nil, err
} }
@ -339,28 +335,37 @@ func validateWebConfig(config *Config) error {
} }
func validateEndpointsConfig(config *Config) error { func validateEndpointsConfig(config *Config) error {
for _, endpoint := range config.Endpoints { duplicateValidationMap := make(map[string]bool)
// Validate endpoints
for _, ep := range config.Endpoints {
if config.Debug { if config.Debug {
log.Printf("[config.validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name) log.Printf("[config.validateEndpointsConfig] Validating endpoint '%s'", ep.Name)
} }
if err := endpoint.ValidateAndSetDefaults(); err != nil { if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid endpoint %s: %w", endpoint.DisplayName(), err) return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
} else {
duplicateValidationMap[endpointKey] = true
}
if err := ep.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
} }
} }
log.Printf("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints)) log.Printf("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
return nil // Validate external endpoints
} for _, ee := range config.ExternalEndpoints {
func validateExternalEndpointsConfig(config *Config) error {
for _, externalEndpoint := range config.ExternalEndpoints {
if config.Debug { if config.Debug {
log.Printf("[config.validateExternalEndpointsConfig] Validating external endpoint '%s'", externalEndpoint.Name) log.Printf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Name)
} }
if err := externalEndpoint.ValidateAndSetDefaults(); err != nil { if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid external endpoint %s: %w", externalEndpoint.DisplayName(), err) return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
} else {
duplicateValidationMap[endpointKey] = true
}
if err := ee.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
} }
} }
log.Printf("[config.validateExternalEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints)) log.Printf("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
return nil return nil
} }
@ -381,9 +386,9 @@ func validateSecurityConfig(config *Config) error {
// validateAlertingConfig validates the alerting configuration // validateAlertingConfig validates the alerting configuration
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert // Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Endpoint.ValidateAndSetDefaults() // returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
// sets the default alert values when none are set. // sets the default alert values when none are set.
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.Endpoint, debug bool) { func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint, debug bool) {
if alertingConfig == nil { if alertingConfig == nil {
log.Printf("[config.validateAlertingConfig] Alerting is not configured") log.Printf("[config.validateAlertingConfig] Alerting is not configured")
return return
@ -392,12 +397,13 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alert.TypeAWSSES, alert.TypeAWSSES,
alert.TypeCustom, alert.TypeCustom,
alert.TypeDiscord, alert.TypeDiscord,
alert.TypeEmail,
alert.TypeGitHub, alert.TypeGitHub,
alert.TypeGitLab, alert.TypeGitLab,
alert.TypeGitea,
alert.TypeGoogleChat, alert.TypeGoogleChat,
alert.TypeGotify, alert.TypeGotify,
alert.TypeJetBrainsSpace, alert.TypeJetBrainsSpace,
alert.TypeEmail,
alert.TypeMatrix, alert.TypeMatrix,
alert.TypeMattermost, alert.TypeMattermost,
alert.TypeMessagebird, alert.TypeMessagebird,
@ -409,6 +415,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alert.TypeTeams, alert.TypeTeams,
alert.TypeTelegram, alert.TypeTelegram,
alert.TypeTwilio, alert.TypeTwilio,
alert.TypeZulip,
} }
var validProviders, invalidProviders []alert.Type var validProviders, invalidProviders []alert.Type
for _, alertType := range alertTypes { for _, alertType := range alertTypes {
@ -417,11 +424,21 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
if alertProvider.IsValid() { if alertProvider.IsValid() {
// Parse alerts with the provider's default alert // Parse alerts with the provider's default alert
if alertProvider.GetDefaultAlert() != nil { if alertProvider.GetDefaultAlert() != nil {
for _, endpoint := range endpoints { for _, ep := range endpoints {
for alertIndex, endpointAlert := range endpoint.Alerts { for alertIndex, endpointAlert := range ep.Alerts {
if alertType == endpointAlert.Type { if alertType == endpointAlert.Type {
if debug { if debug {
log.Printf("[config.validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name) log.Printf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
}
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
}
}
}
for _, ee := range externalEndpoints {
for alertIndex, endpointAlert := range ee.Alerts {
if alertType == endpointAlert.Type {
if debug {
log.Printf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
} }
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert) provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
} }

View File

@ -16,6 +16,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@ -29,13 +30,14 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/web" "github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage" "github.com/TwiN/gatus/v5/storage"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func TestLoadConfiguration(t *testing.T) { func TestLoadConfiguration(t *testing.T) {
yes := true
dir := t.TempDir() dir := t.TempDir()
scenarios := []struct { scenarios := []struct {
name string name string
@ -65,7 +67,7 @@ func TestLoadConfiguration(t *testing.T) {
endpoints: endpoints:
- name: website`, - name: website`,
}, },
expectedError: core.ErrEndpointWithNoURL, expectedError: endpoint.ErrEndpointWithNoURL,
}, },
{ {
name: "config-file-with-endpoint-that-has-no-conditions", name: "config-file-with-endpoint-that-has-no-conditions",
@ -76,7 +78,7 @@ endpoints:
- name: website - name: website
url: https://twin.sh/health`, url: https://twin.sh/health`,
}, },
expectedError: core.ErrEndpointWithNoCondition, expectedError: endpoint.ErrEndpointWithNoCondition,
}, },
{ {
name: "config-file", name: "config-file",
@ -90,11 +92,11 @@ endpoints:
- "[STATUS] == 200"`, - "[STATUS] == 200"`,
}, },
expectedConfig: &Config{ expectedConfig: &Config{
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "website", Name: "website",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []core.Condition{"[STATUS] == 200"}, Conditions: []endpoint.Condition{"[STATUS] == 200"},
}, },
}, },
}, },
@ -136,21 +138,21 @@ endpoints:
- "[BODY].status == UP"`, - "[BODY].status == UP"`,
}, },
expectedConfig: &Config{ expectedConfig: &Config{
Endpoints: []*core.Endpoint{ Endpoints: []*endpoint.Endpoint{
{ {
Name: "one", Name: "one",
URL: "https://example.com", URL: "https://example.com",
Conditions: []core.Condition{"[CONNECTED] == true", "[STATUS] == 200"}, Conditions: []endpoint.Condition{"[CONNECTED] == true", "[STATUS] == 200"},
}, },
{ {
Name: "two", Name: "two",
URL: "https://example.org", URL: "https://example.org",
Conditions: []core.Condition{"len([BODY]) > 0"}, Conditions: []endpoint.Condition{"len([BODY]) > 0"},
}, },
{ {
Name: "three", Name: "three",
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
Conditions: []core.Condition{"[STATUS] == 200", "[BODY].status == UP"}, Conditions: []endpoint.Condition{"[STATUS] == 200", "[BODY].status == UP"},
}, },
}, },
}, },
@ -165,6 +167,8 @@ metrics: true
alerting: alerting:
slack: slack:
webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz
default-alert:
enabled: true
endpoints: endpoints:
- name: example - name: example
@ -179,6 +183,12 @@ alerting:
discord: discord:
webhook-url: https://discord.com/api/webhooks/xxx/yyy webhook-url: https://discord.com/api/webhooks/xxx/yyy
external-endpoints:
- name: ext-ep-test
token: "potato"
alerts:
- type: slack
endpoints: endpoints:
- name: frontend - name: frontend
url: https://example.com url: https://example.com
@ -190,19 +200,32 @@ endpoints:
Metrics: true, Metrics: true,
Alerting: &alerting.Config{ Alerting: &alerting.Config{
Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}, Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"},
Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"}, Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz", DefaultAlert: &alert.Alert{Enabled: &yes}},
}, },
Endpoints: []*core.Endpoint{ ExternalEndpoints: []*endpoint.ExternalEndpoint{
{
Name: "ext-ep-test",
Token: "potato",
Alerts: []*alert.Alert{
{
Type: alert.TypeSlack,
FailureThreshold: 3,
SuccessThreshold: 2,
},
},
},
},
Endpoints: []*endpoint.Endpoint{
{ {
Name: "example", Name: "example",
URL: "https://example.org", URL: "https://example.org",
Interval: 5 * time.Second, Interval: 5 * time.Second,
Conditions: []core.Condition{"[STATUS] == 200"}, Conditions: []endpoint.Condition{"[STATUS] == 200"},
}, },
{ {
Name: "frontend", Name: "frontend",
URL: "https://example.com", URL: "https://example.com",
Conditions: []core.Condition{"[STATUS] == 200"}, Conditions: []endpoint.Condition{"[STATUS] == 200"},
}, },
}, },
}, },
@ -325,10 +348,6 @@ external-endpoints:
- name: ext-ep-test - name: ext-ep-test
group: core group: core
token: "potato" token: "potato"
alerts:
- type: discord
description: "healthcheck failed"
send-on-resolved: true
endpoints: endpoints:
- name: website - name: website
@ -382,18 +401,7 @@ endpoints:
if config.ExternalEndpoints[0].Token != "potato" { if config.ExternalEndpoints[0].Token != "potato" {
t.Errorf("Token should have been %s", "potato") t.Errorf("Token should have been %s", "potato")
} }
if len(config.ExternalEndpoints[0].Alerts) != 1 {
t.Error("Should have returned one alert")
}
if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord {
t.Errorf("Type should have been %s", alert.TypeDiscord)
}
if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 3 {
t.Errorf("FailureThreshold should have been %d, got %d", 3, config.ExternalEndpoints[0].Alerts[0].FailureThreshold)
}
if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 2 {
t.Errorf("SuccessThreshold should have been %d, got %d", 2, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold)
}
if len(config.Endpoints) != 3 { if len(config.Endpoints) != 3 {
t.Error("Should have returned two endpoints") t.Error("Should have returned two endpoints")
} }
@ -439,7 +447,6 @@ endpoints:
if len(config.Endpoints[1].Conditions) != 2 { if len(config.Endpoints[1].Conditions) != 2 {
t.Errorf("There should have been %d conditions", 2) t.Errorf("There should have been %d conditions", 2)
} }
if config.Endpoints[2].URL != "https://example.com/" { if config.Endpoints[2].URL != "https://example.com/" {
t.Errorf("URL should have been %s", "https://example.com/") t.Errorf("URL should have been %s", "https://example.com/")
} }
@ -689,8 +696,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second { if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
} }
if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != core.GatusUserAgent { if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != endpoint.GatusUserAgent {
t.Errorf("User-Agent should've been %s because it's the default value, got %s", core.GatusUserAgent, userAgent) t.Errorf("User-Agent should've been %s because it's the default value, got %s", endpoint.GatusUserAgent, userAgent)
} }
} }
@ -924,7 +931,7 @@ alerting:
default-alert: default-alert:
enabled: true enabled: true
failure-threshold: 10 failure-threshold: 10
success-threshold: 1 success-threshold: 15
pagerduty: pagerduty:
integration-key: "00000000000000000000000000000000" integration-key: "00000000000000000000000000000000"
default-alert: default-alert:
@ -977,6 +984,29 @@ alerting:
enabled: true enabled: true
failure-threshold: 5 failure-threshold: 5
success-threshold: 3 success-threshold: 3
email:
from: "from@example.com"
username: "from@example.com"
password: "hunter2"
host: "mail.example.com"
port: 587
to: "recipient1@example.com,recipient2@example.com"
client:
insecure: false
default-alert:
enabled: true
gotify:
server-url: "https://gotify.example"
token: "**************"
default-alert:
enabled: true
external-endpoints:
- name: ext-ep-test
group: core
token: potato
alerts:
- type: discord
endpoints: endpoints:
- name: website - name: website
@ -987,12 +1017,14 @@ endpoints:
- type: mattermost - type: mattermost
- type: messagebird - type: messagebird
- type: discord - type: discord
success-threshold: 2 # test endpoint alert override success-threshold: 8 # test endpoint alert override
- type: telegram - type: telegram
- type: twilio - type: twilio
- type: teams - type: teams
- type: pushover - type: pushover
- type: jetbrainsspace - type: jetbrainsspace
- type: email
- type: gotify
conditions: conditions:
- "[STATUS] == 200" - "[STATUS] == 200"
`)) `))
@ -1071,6 +1103,12 @@ endpoints:
if config.Alerting.Discord.GetDefaultAlert() == nil { if config.Alerting.Discord.GetDefaultAlert() == nil {
t.Fatal("Discord.GetDefaultAlert() shouldn't have returned nil") t.Fatal("Discord.GetDefaultAlert() shouldn't have returned nil")
} }
if config.Alerting.Discord.GetDefaultAlert().FailureThreshold != 10 {
t.Errorf("Discord default alert failure threshold should've been %d, but was %d", 10, config.Alerting.Discord.GetDefaultAlert().FailureThreshold)
}
if config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 {
t.Errorf("Discord default alert success threshold should've been %d, but was %d", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold)
}
if config.Alerting.Discord.WebhookURL != "http://example.org" { if config.Alerting.Discord.WebhookURL != "http://example.org" {
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL) t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL)
} }
@ -1107,6 +1145,7 @@ endpoints:
if config.Alerting.JetBrainsSpace == nil || !config.Alerting.JetBrainsSpace.IsValid() { if config.Alerting.JetBrainsSpace == nil || !config.Alerting.JetBrainsSpace.IsValid() {
t.Fatal("JetBrainsSpace alerting config should've been valid") t.Fatal("JetBrainsSpace alerting config should've been valid")
} }
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil { if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil") t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
} }
@ -1120,6 +1159,67 @@ endpoints:
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.Token) t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.Token)
} }
if config.Alerting.Email == nil || !config.Alerting.Email.IsValid() {
t.Fatal("Email alerting config should've been valid")
}
if config.Alerting.Email.GetDefaultAlert() == nil {
t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Email.From != "from@example.com" {
t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.From)
}
if config.Alerting.Email.Username != "from@example.com" {
t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.Username)
}
if config.Alerting.Email.Password != "hunter2" {
t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.Password)
}
if config.Alerting.Email.Host != "mail.example.com" {
t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.Host)
}
if config.Alerting.Email.Port != 587 {
t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.Port)
}
if config.Alerting.Email.To != "recipient1@example.com,recipient2@example.com" {
t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.To)
}
if config.Alerting.Email.ClientConfig == nil {
t.Fatal("Email client config should've been set")
}
if config.Alerting.Email.ClientConfig.Insecure {
t.Error("Email client config should've been secure")
}
if config.Alerting.Gotify == nil || !config.Alerting.Gotify.IsValid() {
t.Fatal("Gotify alerting config should've been valid")
}
if config.Alerting.Gotify.GetDefaultAlert() == nil {
t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Gotify.ServerURL != "https://gotify.example" {
t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.ServerURL)
}
if config.Alerting.Gotify.Token != "**************" {
t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.Token)
}
// External endpoints
if len(config.ExternalEndpoints) != 1 {
t.Error("There should've been 1 external endpoint")
}
if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.ExternalEndpoints[0].Alerts[0].Type)
}
if !config.ExternalEndpoints[0].Alerts[0].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 10 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.ExternalEndpoints[0].Alerts[0].FailureThreshold)
}
if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 15 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold)
}
// Endpoints // Endpoints
if len(config.Endpoints) != 1 { if len(config.Endpoints) != 1 {
t.Error("There should've been 1 endpoint") t.Error("There should've been 1 endpoint")
@ -1130,8 +1230,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second { if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
} }
if len(config.Endpoints[0].Alerts) != 10 { if len(config.Endpoints[0].Alerts) != 12 {
t.Fatal("There should've been 10 alerts configured") t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts))
} }
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack { if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@ -1192,8 +1292,8 @@ endpoints:
if config.Endpoints[0].Alerts[4].FailureThreshold != 10 { if config.Endpoints[0].Alerts[4].FailureThreshold != 10 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold) t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold)
} }
if config.Endpoints[0].Alerts[4].SuccessThreshold != 2 { if config.Endpoints[0].Alerts[4].SuccessThreshold != 8 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[4].SuccessThreshold) t.Errorf("The default success threshold of the alert should've been %d because it was explicitly overriden, but it was %d", 8, config.Endpoints[0].Alerts[4].SuccessThreshold)
} }
if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram { if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram {
@ -1255,10 +1355,36 @@ endpoints:
t.Error("The alert should've been enabled") t.Error("The alert should've been enabled")
} }
if config.Endpoints[0].Alerts[9].FailureThreshold != 5 { if config.Endpoints[0].Alerts[9].FailureThreshold != 5 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].FailureThreshold) 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].SuccessThreshold != 3 { 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", 2, config.Endpoints[0].Alerts[9].SuccessThreshold) 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[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].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[10].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[10].FailureThreshold)
}
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)
} }
} }
@ -1532,6 +1658,99 @@ endpoints:
} }
} }
func TestParseAndValidateConfigBytesWithDuplicateEndpointName(t *testing.T) {
scenarios := []struct {
name string
shouldError bool
config string
}{
{
name: "same-name-no-group",
shouldError: true,
config: `
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-different-group",
shouldError: false,
config: `
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- name: ep1
group: g1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-same-group",
shouldError: true,
config: `
endpoints:
- name: ep1
group: g1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- name: ep1
group: g1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-different-endpoint-type",
shouldError: true,
config: `
external-endpoints:
- name: ep1
token: "12345678"
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-different-group-different-endpoint-type",
shouldError: false,
config: `
external-endpoints:
- name: ep1
group: gr1
token: "12345678"
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
if scenario.shouldError && err == nil {
t.Error("should've returned an error")
} else if !scenario.shouldError && err != nil {
t.Error("shouldn't have returned an error")
}
})
}
}
func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) { func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(` _, err := parseAndValidateConfigBytes([]byte(`
storage: storage:
@ -1645,7 +1864,7 @@ endpoints:
func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) { func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(``)) _, err := parseAndValidateConfigBytes([]byte(``))
if err != ErrNoEndpointInConfig { if !errors.Is(err, ErrNoEndpointInConfig) {
t.Error("The error returned should have been of type ErrNoEndpointInConfig") t.Error("The error returned should have been of type ErrNoEndpointInConfig")
} }
} }
@ -1657,6 +1876,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
Email: &email.AlertProvider{}, Email: &email.AlertProvider{},
GitHub: &github.AlertProvider{}, GitHub: &github.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{}, GoogleChat: &googlechat.AlertProvider{},
Gotify: &gotify.AlertProvider{},
JetBrainsSpace: &jetbrainsspace.AlertProvider{}, JetBrainsSpace: &jetbrainsspace.AlertProvider{},
Matrix: &matrix.AlertProvider{}, Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{}, Mattermost: &mattermost.AlertProvider{},
@ -1679,6 +1899,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypeEmail, expected: alertingConfig.Email}, {alertType: alert.TypeEmail, expected: alertingConfig.Email},
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub}, {alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat}, {alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace}, {alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix}, {alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost}, {alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},

View File

@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"errors" "errors"

View File

@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"errors" "errors"

View File

@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"errors" "errors"

View File

@ -1,6 +1,8 @@
package core package endpoint
import "testing" import (
"testing"
)
func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) { func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)") condition := Condition("[BODY].name == any(john.doe, jane.doe)")

View File

@ -1,4 +1,4 @@
package core package endpoint
// ConditionResult result of a Condition // ConditionResult result of a Condition
type ConditionResult struct { type ConditionResult struct {

View File

@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"errors" "errors"

View File

@ -0,0 +1,38 @@
package dns
import (
"errors"
"strings"
"github.com/miekg/dns"
)
var (
// ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name
ErrDNSWithNoQueryName = errors.New("you must specify a query name in the DNS configuration")
// ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type
ErrDNSWithInvalidQueryType = errors.New("invalid query type in the DNS configuration")
)
// Config for an Endpoint of type DNS
type Config struct {
// QueryType is the type for the DNS records like A, AAAA, CNAME...
QueryType string `yaml:"query-type"`
// QueryName is the query for DNS
QueryName string `yaml:"query-name"`
}
func (d *Config) ValidateAndSetDefault() error {
if len(d.QueryName) == 0 {
return ErrDNSWithNoQueryName
}
if !strings.HasSuffix(d.QueryName, ".") {
d.QueryName += "."
}
if _, ok := dns.StringToType[d.QueryType]; !ok {
return ErrDNSWithInvalidQueryType
}
return nil
}

View File

@ -0,0 +1,27 @@
package dns
import (
"testing"
)
func TestConfig_ValidateAndSetDefault(t *testing.T) {
dns := &Config{
QueryType: "A",
QueryName: "",
}
err := dns.ValidateAndSetDefault()
if err == nil {
t.Error("Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns")
}
}
func TestConfig_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {
dns := &Config{
QueryType: "B",
QueryName: "example.com",
}
err := dns.ValidateAndSetDefault()
if err == nil {
t.Error("Should've returned an error because endpoint's dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...")
}
}

View File

@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"bytes" "bytes"
@ -16,12 +16,13 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/util" sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
type EndpointType string type Type string
const ( const (
// HostHeader is the name of the header used to specify the host // HostHeader is the name of the header used to specify the host
@ -36,17 +37,17 @@ const (
// GatusUserAgent is the default user agent that Gatus uses to send requests. // GatusUserAgent is the default user agent that Gatus uses to send requests.
GatusUserAgent = "Gatus/1.0" GatusUserAgent = "Gatus/1.0"
EndpointTypeDNS EndpointType = "DNS" TypeDNS Type = "DNS"
EndpointTypeTCP EndpointType = "TCP" TypeTCP Type = "TCP"
EndpointTypeSCTP EndpointType = "SCTP" TypeSCTP Type = "SCTP"
EndpointTypeUDP EndpointType = "UDP" TypeUDP Type = "UDP"
EndpointTypeICMP EndpointType = "ICMP" TypeICMP Type = "ICMP"
EndpointTypeSTARTTLS EndpointType = "STARTTLS" TypeSTARTTLS Type = "STARTTLS"
EndpointTypeTLS EndpointType = "TLS" TypeTLS Type = "TLS"
EndpointTypeHTTP EndpointType = "HTTP" TypeHTTP Type = "HTTP"
EndpointTypeWS EndpointType = "WEBSOCKET" TypeWS Type = "WEBSOCKET"
EndpointTypeSSH EndpointType = "SSH" TypeSSH Type = "SSH"
EndpointTypeUNKNOWN EndpointType = "UNKNOWN" TypeUNKNOWN Type = "UNKNOWN"
) )
var ( var (
@ -83,12 +84,6 @@ type Endpoint struct {
// URL to send the request to // URL to send the request to
URL string `yaml:"url"` URL string `yaml:"url"`
// DNS is the configuration of DNS monitoring
DNS *DNS `yaml:"dns,omitempty"`
// SSH is the configuration of SSH monitoring.
SSH *SSH `yaml:"ssh,omitempty"`
// Method of the request made to the url of the endpoint // Method of the request made to the url of the endpoint
Method string `yaml:"method,omitempty"` Method string `yaml:"method,omitempty"`
@ -110,6 +105,12 @@ type Endpoint struct {
// Alerts is the alerting configuration for the endpoint in case of failure // Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"` Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// DNSConfig is the configuration for DNS monitoring
DNSConfig *dns.Config `yaml:"dns,omitempty"`
// SSH is the configuration for SSH monitoring
SSHConfig *sshconfig.Config `yaml:"ssh,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the endpoint's target // ClientConfig is the configuration of the client used to communicate with the endpoint's target
ClientConfig *client.Config `yaml:"client,omitempty"` ClientConfig *client.Config `yaml:"client,omitempty"`
@ -127,103 +128,103 @@ type Endpoint struct {
} }
// IsEnabled returns whether the endpoint is enabled or not // IsEnabled returns whether the endpoint is enabled or not
func (endpoint *Endpoint) IsEnabled() bool { func (e *Endpoint) IsEnabled() bool {
if endpoint.Enabled == nil { if e.Enabled == nil {
return true return true
} }
return *endpoint.Enabled return *e.Enabled
} }
// Type returns the endpoint type // Type returns the endpoint type
func (endpoint *Endpoint) Type() EndpointType { func (e *Endpoint) Type() Type {
switch { switch {
case endpoint.DNS != nil: case e.DNSConfig != nil:
return EndpointTypeDNS return TypeDNS
case strings.HasPrefix(endpoint.URL, "tcp://"): case strings.HasPrefix(e.URL, "tcp://"):
return EndpointTypeTCP return TypeTCP
case strings.HasPrefix(endpoint.URL, "sctp://"): case strings.HasPrefix(e.URL, "sctp://"):
return EndpointTypeSCTP return TypeSCTP
case strings.HasPrefix(endpoint.URL, "udp://"): case strings.HasPrefix(e.URL, "udp://"):
return EndpointTypeUDP return TypeUDP
case strings.HasPrefix(endpoint.URL, "icmp://"): case strings.HasPrefix(e.URL, "icmp://"):
return EndpointTypeICMP return TypeICMP
case strings.HasPrefix(endpoint.URL, "starttls://"): case strings.HasPrefix(e.URL, "starttls://"):
return EndpointTypeSTARTTLS return TypeSTARTTLS
case strings.HasPrefix(endpoint.URL, "tls://"): case strings.HasPrefix(e.URL, "tls://"):
return EndpointTypeTLS return TypeTLS
case strings.HasPrefix(endpoint.URL, "http://") || strings.HasPrefix(endpoint.URL, "https://"): case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
return EndpointTypeHTTP return TypeHTTP
case strings.HasPrefix(endpoint.URL, "ws://") || strings.HasPrefix(endpoint.URL, "wss://"): case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
return EndpointTypeWS return TypeWS
case strings.HasPrefix(endpoint.URL, "ssh://"): case strings.HasPrefix(e.URL, "ssh://"):
return EndpointTypeSSH return TypeSSH
default: default:
return EndpointTypeUNKNOWN return TypeUNKNOWN
} }
} }
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one // ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one
func (endpoint *Endpoint) ValidateAndSetDefaults() error { func (e *Endpoint) ValidateAndSetDefaults() error {
if err := validateEndpointNameGroupAndAlerts(endpoint.Name, endpoint.Group, endpoint.Alerts); err != nil { if err := validateEndpointNameGroupAndAlerts(e.Name, e.Group, e.Alerts); err != nil {
return err return err
} }
if len(endpoint.URL) == 0 { if len(e.URL) == 0 {
return ErrEndpointWithNoURL return ErrEndpointWithNoURL
} }
if endpoint.ClientConfig == nil { if e.ClientConfig == nil {
endpoint.ClientConfig = client.GetDefaultConfig() e.ClientConfig = client.GetDefaultConfig()
} else { } else {
if err := endpoint.ClientConfig.ValidateAndSetDefaults(); err != nil { if err := e.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err return err
} }
} }
if endpoint.UIConfig == nil { if e.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig() e.UIConfig = ui.GetDefaultConfig()
} else { } else {
if err := endpoint.UIConfig.ValidateAndSetDefaults(); err != nil { if err := e.UIConfig.ValidateAndSetDefaults(); err != nil {
return err return err
} }
} }
if endpoint.Interval == 0 { if e.Interval == 0 {
endpoint.Interval = 1 * time.Minute e.Interval = 1 * time.Minute
} }
if len(endpoint.Method) == 0 { if len(e.Method) == 0 {
endpoint.Method = http.MethodGet e.Method = http.MethodGet
} }
if len(endpoint.Headers) == 0 { if len(e.Headers) == 0 {
endpoint.Headers = make(map[string]string) e.Headers = make(map[string]string)
} }
// Automatically add user agent header if there isn't one specified in the endpoint configuration // Automatically add user agent header if there isn't one specified in the endpoint configuration
if _, userAgentHeaderExists := endpoint.Headers[UserAgentHeader]; !userAgentHeaderExists { if _, userAgentHeaderExists := e.Headers[UserAgentHeader]; !userAgentHeaderExists {
endpoint.Headers[UserAgentHeader] = GatusUserAgent e.Headers[UserAgentHeader] = GatusUserAgent
} }
// Automatically add "Content-Type: application/json" header if there's no Content-Type set // Automatically add "Content-Type: application/json" header if there's no Content-Type set
// and endpoint.GraphQL is set to true // and endpoint.GraphQL is set to true
if _, contentTypeHeaderExists := endpoint.Headers[ContentTypeHeader]; !contentTypeHeaderExists && endpoint.GraphQL { if _, contentTypeHeaderExists := e.Headers[ContentTypeHeader]; !contentTypeHeaderExists && e.GraphQL {
endpoint.Headers[ContentTypeHeader] = "application/json" e.Headers[ContentTypeHeader] = "application/json"
} }
if len(endpoint.Conditions) == 0 { if len(e.Conditions) == 0 {
return ErrEndpointWithNoCondition return ErrEndpointWithNoCondition
} }
for _, c := range endpoint.Conditions { for _, c := range e.Conditions {
if endpoint.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() { if e.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() {
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
} }
if err := c.Validate(); err != nil { if err := c.Validate(); err != nil {
return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err) return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err)
} }
} }
if endpoint.DNS != nil { if e.DNSConfig != nil {
return endpoint.DNS.validateAndSetDefault() return e.DNSConfig.ValidateAndSetDefault()
} }
if endpoint.SSH != nil { if e.SSHConfig != nil {
return endpoint.SSH.validate() return e.SSHConfig.Validate()
} }
if endpoint.Type() == EndpointTypeUNKNOWN { if e.Type() == TypeUNKNOWN {
return ErrUnknownEndpointType return ErrUnknownEndpointType
} }
// Make sure that the request can be created // Make sure that the request can be created
_, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body))) _, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body)))
if err != nil { if err != nil {
return err return err
} }
@ -231,35 +232,35 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
} }
// DisplayName returns an identifier made up of the Name and, if not empty, the Group. // DisplayName returns an identifier made up of the Name and, if not empty, the Group.
func (endpoint *Endpoint) DisplayName() string { func (e *Endpoint) DisplayName() string {
if len(endpoint.Group) > 0 { if len(e.Group) > 0 {
return endpoint.Group + "/" + endpoint.Name return e.Group + "/" + e.Name
} }
return endpoint.Name return e.Name
} }
// Key returns the unique key for the Endpoint // Key returns the unique key for the Endpoint
func (endpoint *Endpoint) Key() string { func (e *Endpoint) Key() string {
return util.ConvertGroupAndEndpointNameToKey(endpoint.Group, endpoint.Name) return ConvertGroupAndEndpointNameToKey(e.Group, e.Name)
} }
// Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors // Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors
// on configuration reload. // on configuration reload.
// More context on https://github.com/TwiN/gatus/issues/536 // More context on https://github.com/TwiN/gatus/issues/536
func (endpoint *Endpoint) Close() { func (e *Endpoint) Close() {
if endpoint.Type() == EndpointTypeHTTP { if e.Type() == TypeHTTP {
client.GetHTTPClient(endpoint.ClientConfig).CloseIdleConnections() client.GetHTTPClient(e.ClientConfig).CloseIdleConnections()
} }
} }
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint. // EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
func (endpoint *Endpoint) EvaluateHealth() *Result { func (e *Endpoint) EvaluateHealth() *Result {
result := &Result{Success: true, Errors: []string{}} result := &Result{Success: true, Errors: []string{}}
// Parse or extract hostname from URL // Parse or extract hostname from URL
if endpoint.DNS != nil { if e.DNSConfig != nil {
result.Hostname = strings.TrimSuffix(endpoint.URL, ":53") result.Hostname = strings.TrimSuffix(e.URL, ":53")
} else { } else {
urlObject, err := url.Parse(endpoint.URL) urlObject, err := url.Parse(e.URL)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
} else { } else {
@ -267,11 +268,11 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
} }
} }
// Retrieve IP if necessary // Retrieve IP if necessary
if endpoint.needsToRetrieveIP() { if e.needsToRetrieveIP() {
endpoint.getIP(result) e.getIP(result)
} }
// Retrieve domain expiration if necessary // Retrieve domain expiration if necessary
if endpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 { if e.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
var err error var err error
if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil { if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@ -279,31 +280,31 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
} }
// Call the endpoint (if there's no errors) // Call the endpoint (if there's no errors)
if len(result.Errors) == 0 { if len(result.Errors) == 0 {
endpoint.call(result) e.call(result)
} else { } else {
result.Success = false result.Success = false
} }
// Evaluate the conditions // Evaluate the conditions
for _, condition := range endpoint.Conditions { for _, condition := range e.Conditions {
success := condition.evaluate(result, endpoint.UIConfig.DontResolveFailedConditions) success := condition.evaluate(result, e.UIConfig.DontResolveFailedConditions)
if !success { if !success {
result.Success = false result.Success = false
} }
} }
result.Timestamp = time.Now() result.Timestamp = time.Now()
// Clean up parameters that we don't need to keep in the results // Clean up parameters that we don't need to keep in the results
if endpoint.UIConfig.HideURL { if e.UIConfig.HideURL {
for errIdx, errorString := range result.Errors { for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, endpoint.URL, "<redacted>") result.Errors[errIdx] = strings.ReplaceAll(errorString, e.URL, "<redacted>")
} }
} }
if endpoint.UIConfig.HideHostname { if e.UIConfig.HideHostname {
for errIdx, errorString := range result.Errors { for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>") result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
} }
result.Hostname = "" result.Hostname = ""
} }
if endpoint.UIConfig.HideConditions { if e.UIConfig.HideConditions {
result.ConditionResults = nil result.ConditionResults = nil
} }
@ -328,7 +329,7 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
return result return result
} }
func (endpoint *Endpoint) getIP(result *Result) { func (e *Endpoint) getIP(result *Result) {
if ips, err := net.LookupIP(result.Hostname); err != nil { if ips, err := net.LookupIP(result.Hostname); err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
@ -337,24 +338,28 @@ func (endpoint *Endpoint) getIP(result *Result) {
} }
} }
func (endpoint *Endpoint) call(result *Result) { func (e *Endpoint) call(result *Result) {
var request *http.Request var request *http.Request
var response *http.Response var response *http.Response
var err error var err error
var certificate *x509.Certificate var certificate *x509.Certificate
endpointType := endpoint.Type() endpointType := e.Type()
if endpointType == EndpointTypeHTTP { if endpointType == TypeHTTP {
request = endpoint.buildHTTPRequest() request = e.buildHTTPRequest()
} }
startTime := time.Now() startTime := time.Now()
if endpointType == EndpointTypeDNS { if endpointType == TypeDNS {
endpoint.DNS.query(endpoint.URL, result) result.Connected, result.DNSRCode, result.Body, err = client.QueryDNS(e.DNSConfig.QueryType, e.DNSConfig.QueryName, e.URL)
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS { } else if endpointType == TypeSTARTTLS || endpointType == TypeTLS {
if endpointType == EndpointTypeSTARTTLS { if endpointType == TypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig) result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig)
} else { } else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig) result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.ClientConfig)
} }
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@ -362,39 +367,39 @@ func (endpoint *Endpoint) call(result *Result) {
} }
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter) result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if endpointType == EndpointTypeTCP { } else if endpointType == TypeTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig) result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(e.URL, "tcp://"), e.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeUDP { } else if endpointType == TypeUDP {
result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(endpoint.URL, "udp://"), endpoint.ClientConfig) result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(e.URL, "udp://"), e.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeSCTP { } else if endpointType == TypeSCTP {
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(endpoint.URL, "sctp://"), endpoint.ClientConfig) result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeICMP { } else if endpointType == TypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig) result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig)
} else if endpointType == EndpointTypeWS { } else if endpointType == TypeWS {
result.Connected, result.Body, err = client.QueryWebSocket(endpoint.URL, endpoint.Body, endpoint.ClientConfig) result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.Body, e.ClientConfig)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
} }
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == EndpointTypeSSH { } else if endpointType == TypeSSH {
var cli *ssh.Client var cli *ssh.Client
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(endpoint.URL, "ssh://"), endpoint.SSH.Username, endpoint.SSH.Password, endpoint.ClientConfig) result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
} }
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, endpoint.Body, endpoint.ClientConfig) result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.Body, e.ClientConfig)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
} }
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else { } else {
response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request) response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@ -408,7 +413,7 @@ func (endpoint *Endpoint) call(result *Result) {
result.HTTPStatus = response.StatusCode result.HTTPStatus = response.StatusCode
result.Connected = response.StatusCode > 0 result.Connected = response.StatusCode > 0
// Only read the Body if there's a condition that uses the BodyPlaceholder // Only read the Body if there's a condition that uses the BodyPlaceholder
if endpoint.needsToReadBody() { if e.needsToReadBody() {
result.Body, err = io.ReadAll(response.Body) result.Body, err = io.ReadAll(response.Body)
if err != nil { if err != nil {
result.AddError("error reading response body:" + err.Error()) result.AddError("error reading response body:" + err.Error())
@ -417,19 +422,19 @@ func (endpoint *Endpoint) call(result *Result) {
} }
} }
func (endpoint *Endpoint) buildHTTPRequest() *http.Request { func (e *Endpoint) buildHTTPRequest() *http.Request {
var bodyBuffer *bytes.Buffer var bodyBuffer *bytes.Buffer
if endpoint.GraphQL { if e.GraphQL {
graphQlBody := map[string]string{ graphQlBody := map[string]string{
"query": endpoint.Body, "query": e.Body,
} }
body, _ := json.Marshal(graphQlBody) body, _ := json.Marshal(graphQlBody)
bodyBuffer = bytes.NewBuffer(body) bodyBuffer = bytes.NewBuffer(body)
} else { } else {
bodyBuffer = bytes.NewBuffer([]byte(endpoint.Body)) bodyBuffer = bytes.NewBuffer([]byte(e.Body))
} }
request, _ := http.NewRequest(endpoint.Method, endpoint.URL, bodyBuffer) request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
for k, v := range endpoint.Headers { for k, v := range e.Headers {
request.Header.Set(k, v) request.Header.Set(k, v)
if k == HostHeader { if k == HostHeader {
request.Host = v request.Host = v
@ -439,11 +444,11 @@ func (endpoint *Endpoint) buildHTTPRequest() *http.Request {
} }
// needsToReadBody checks if there's any condition that requires the response Body to be read // needsToReadBody checks if there's any condition that requires the response Body to be read
func (endpoint *Endpoint) needsToReadBody() bool { func (e *Endpoint) needsToReadBody() bool {
if strings.Contains(endpoint.OnErrorAdd, BodyPlaceholder) { if strings.Contains(e.OnErrorAdd, BodyPlaceholder) {
return true return true
} }
for _, condition := range endpoint.Conditions { for _, condition := range e.Conditions {
if condition.hasBodyPlaceholder() { if condition.hasBodyPlaceholder() {
return true return true
} }
@ -452,8 +457,8 @@ func (endpoint *Endpoint) needsToReadBody() bool {
} }
// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed // needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed
func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool { func (e *Endpoint) needsToRetrieveDomainExpiration() bool {
for _, condition := range endpoint.Conditions { for _, condition := range e.Conditions {
if condition.hasDomainExpirationPlaceholder() { if condition.hasDomainExpirationPlaceholder() {
return true return true
} }
@ -462,8 +467,8 @@ func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool {
} }
// needsToRetrieveIP checks if there's any condition that requires an IP lookup // needsToRetrieveIP checks if there's any condition that requires an IP lookup
func (endpoint *Endpoint) needsToRetrieveIP() bool { func (e *Endpoint) needsToRetrieveIP() bool {
for _, condition := range endpoint.Conditions { for _, condition := range e.Conditions {
if condition.hasIPPlaceholder() { if condition.hasIPPlaceholder() {
return true return true
} }

View File

@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"bytes" "bytes"
@ -13,7 +13,9 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
@ -302,105 +304,105 @@ func TestEndpoint_IsEnabled(t *testing.T) {
func TestEndpoint_Type(t *testing.T) { func TestEndpoint_Type(t *testing.T) {
type args struct { type args struct {
URL string URL string
DNS *DNS DNS *dns.Config
SSH *SSH SSH *ssh.Config
} }
tests := []struct { tests := []struct {
args args args args
want EndpointType want Type
}{ }{
{ {
args: args{ args: args{
URL: "8.8.8.8", URL: "8.8.8.8",
DNS: &DNS{ DNS: &dns.Config{
QueryType: "A", QueryType: "A",
QueryName: "example.com", QueryName: "example.com",
}, },
}, },
want: EndpointTypeDNS, want: TypeDNS,
}, },
{ {
args: args{ args: args{
URL: "tcp://127.0.0.1:6379", URL: "tcp://127.0.0.1:6379",
}, },
want: EndpointTypeTCP, want: TypeTCP,
}, },
{ {
args: args{ args: args{
URL: "icmp://example.com", URL: "icmp://example.com",
}, },
want: EndpointTypeICMP, want: TypeICMP,
}, },
{ {
args: args{ args: args{
URL: "sctp://example.com", URL: "sctp://example.com",
}, },
want: EndpointTypeSCTP, want: TypeSCTP,
}, },
{ {
args: args{ args: args{
URL: "udp://example.com", URL: "udp://example.com",
}, },
want: EndpointTypeUDP, want: TypeUDP,
}, },
{ {
args: args{ args: args{
URL: "starttls://smtp.gmail.com:587", URL: "starttls://smtp.gmail.com:587",
}, },
want: EndpointTypeSTARTTLS, want: TypeSTARTTLS,
}, },
{ {
args: args{ args: args{
URL: "tls://example.com:443", URL: "tls://example.com:443",
}, },
want: EndpointTypeTLS, want: TypeTLS,
}, },
{ {
args: args{ args: args{
URL: "https://twin.sh/health", URL: "https://twin.sh/health",
}, },
want: EndpointTypeHTTP, want: TypeHTTP,
}, },
{ {
args: args{ args: args{
URL: "wss://example.com/", URL: "wss://example.com/",
}, },
want: EndpointTypeWS, want: TypeWS,
}, },
{ {
args: args{ args: args{
URL: "ws://example.com/", URL: "ws://example.com/",
}, },
want: EndpointTypeWS, want: TypeWS,
}, },
{ {
args: args{ args: args{
URL: "ssh://example.com:22", URL: "ssh://example.com:22",
SSH: &SSH{ SSH: &ssh.Config{
Username: "root", Username: "root",
Password: "password", Password: "password",
}, },
}, },
want: EndpointTypeSSH, want: TypeSSH,
}, },
{ {
args: args{ args: args{
URL: "invalid://example.org", URL: "invalid://example.org",
}, },
want: EndpointTypeUNKNOWN, want: TypeUNKNOWN,
}, },
{ {
args: args{ args: args{
URL: "no-scheme", URL: "no-scheme",
}, },
want: EndpointTypeUNKNOWN, want: TypeUNKNOWN,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(string(tt.want), func(t *testing.T) { t.Run(string(tt.want), func(t *testing.T) {
endpoint := Endpoint{ endpoint := Endpoint{
URL: tt.args.URL, URL: tt.args.URL,
DNS: tt.args.DNS, DNSConfig: tt.args.DNS,
} }
if got := endpoint.Type(); got != tt.want { if got := endpoint.Type(); got != tt.want {
t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want) t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want)
@ -500,7 +502,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
endpoint := &Endpoint{ endpoint := &Endpoint{
Name: "dns-test", Name: "dns-test",
URL: "https://example.com", URL: "https://example.com",
DNS: &DNS{ DNSConfig: &dns.Config{
QueryType: "A", QueryType: "A",
QueryName: "example.com", QueryName: "example.com",
}, },
@ -510,7 +512,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
if err != nil { if err != nil {
t.Error("did not expect an error, got", err) t.Error("did not expect an error, got", err)
} }
if endpoint.DNS.QueryName != "example.com." { if endpoint.DNSConfig.QueryName != "example.com." {
t.Error("Endpoint.dns.query-name should be formatted with . suffix") t.Error("Endpoint.dns.query-name should be formatted with . suffix")
} }
} }
@ -526,13 +528,13 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
name: "fail when has no user", name: "fail when has no user",
username: "", username: "",
password: "password", password: "password",
expectedErr: ErrEndpointWithoutSSHUsername, expectedErr: ssh.ErrEndpointWithoutSSHUsername,
}, },
{ {
name: "fail when has no password", name: "fail when has no password",
username: "username", username: "username",
password: "", password: "",
expectedErr: ErrEndpointWithoutSSHPassword, expectedErr: ssh.ErrEndpointWithoutSSHPassword,
}, },
{ {
name: "success when all fields are set", name: "success when all fields are set",
@ -547,7 +549,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
endpoint := &Endpoint{ endpoint := &Endpoint{
Name: "ssh-test", Name: "ssh-test",
URL: "https://example.com", URL: "https://example.com",
SSH: &SSH{ SSHConfig: &ssh.Config{
Username: scenario.username, Username: scenario.username,
Password: scenario.password, Password: scenario.password,
}, },
@ -786,7 +788,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
endpoint := Endpoint{ endpoint := Endpoint{
Name: "example", Name: "example",
URL: "8.8.8.8", URL: "8.8.8.8",
DNS: &DNS{ DNSConfig: &dns.Config{
QueryType: "A", QueryType: "A",
QueryName: "example.com.", QueryName: "example.com.",
}, },
@ -809,7 +811,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
} }
func TestIntegrationEvaluateHealthForSSH(t *testing.T) { func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
tests := []struct { scenarios := []struct {
name string name string
endpoint Endpoint endpoint Endpoint
conditions []Condition conditions []Condition
@ -820,9 +822,9 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
endpoint: Endpoint{ endpoint: Endpoint{
Name: "ssh-success", Name: "ssh-success",
URL: "ssh://localhost", URL: "ssh://localhost",
SSH: &SSH{ SSHConfig: &ssh.Config{
Username: "test", Username: "scenario",
Password: "test", Password: "scenario",
}, },
Body: "{ \"command\": \"uptime\" }", Body: "{ \"command\": \"uptime\" }",
}, },
@ -834,9 +836,9 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
endpoint: Endpoint{ endpoint: Endpoint{
Name: "ssh-failure", Name: "ssh-failure",
URL: "ssh://localhost", URL: "ssh://localhost",
SSH: &SSH{ SSHConfig: &ssh.Config{
Username: "test", Username: "scenario",
Password: "test", Password: "scenario",
}, },
Body: "{ \"command\": \"uptime\" }", Body: "{ \"command\": \"uptime\" }",
}, },
@ -845,13 +847,13 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
}, },
} }
for _, test := range tests { for _, scenario := range scenarios {
t.Run(test.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
test.endpoint.ValidateAndSetDefaults() scenario.endpoint.ValidateAndSetDefaults()
test.endpoint.Conditions = test.conditions scenario.endpoint.Conditions = scenario.conditions
result := test.endpoint.EvaluateHealth() result := scenario.endpoint.EvaluateHealth()
if result.Success != test.success { if result.Success != scenario.success {
t.Errorf("Expected success to be %v, but was %v", test.success, result.Success) t.Errorf("Expected success to be %v, but was %v", scenario.success, result.Success)
} }
}) })
} }

View File

@ -1,6 +1,8 @@
package core package endpoint
import "time" import (
"time"
)
// Event is something that happens at a specific time // Event is something that happens at a specific time
type Event struct { type Event struct {

View File

@ -1,6 +1,8 @@
package core package endpoint
import "testing" import (
"testing"
)
func TestNewEventFromResult(t *testing.T) { func TestNewEventFromResult(t *testing.T) {
if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy { if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy {

View File

@ -1,10 +1,9 @@
package core package endpoint
import ( import (
"errors" "errors"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/util"
) )
var ( var (
@ -46,11 +45,6 @@ func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error {
if len(externalEndpoint.Token) == 0 { if len(externalEndpoint.Token) == 0 {
return ErrExternalEndpointWithNoToken return ErrExternalEndpointWithNoToken
} }
for _, externalEndpointAlert := range externalEndpoint.Alerts {
if err := externalEndpointAlert.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil return nil
} }
@ -72,7 +66,7 @@ func (externalEndpoint *ExternalEndpoint) DisplayName() string {
// Key returns the unique key for the Endpoint // Key returns the unique key for the Endpoint
func (externalEndpoint *ExternalEndpoint) Key() string { func (externalEndpoint *ExternalEndpoint) Key() string {
return util.ConvertGroupAndEndpointNameToKey(externalEndpoint.Group, externalEndpoint.Name) return ConvertGroupAndEndpointNameToKey(externalEndpoint.Group, externalEndpoint.Name)
} }
// ToEndpoint converts the ExternalEndpoint to an Endpoint // ToEndpoint converts the ExternalEndpoint to an Endpoint

View File

@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"testing" "testing"

View File

@ -1,4 +1,4 @@
package util package endpoint
import "strings" import "strings"

View File

@ -1,4 +1,4 @@
package util package endpoint
import ( import (
"testing" "testing"

View File

@ -1,4 +1,4 @@
package util package endpoint
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package core package endpoint
import ( import (
"time" "time"
@ -29,7 +29,7 @@ type Result struct {
// Errors encountered during the evaluation of the Endpoint's health // Errors encountered during the evaluation of the Endpoint's health
Errors []string `json:"errors,omitempty"` Errors []string `json:"errors,omitempty"`
// ConditionResults results of the Endpoint's conditions // ConditionResults are the results of each of the Endpoint's Condition
ConditionResults []*ConditionResult `json:"conditionResults,omitempty"` ConditionResults []*ConditionResult `json:"conditionResults,omitempty"`
// Success whether the result signifies a success or not // Success whether the result signifies a success or not

Some files were not shown because too many files have changed in this diff Show More