Compare commits

...
60 Commits
Author SHA1 Message Date
TwiN 379ec2983d refactor(announcements): Move duplicate markdown code into utils/markdown.js 2025-11-08 13:54:36 -05:00
SworyzandGitHub 907716289c feat(announcements): add markdown support (#1371)
* feat(announcements): add markdown support

* feat(announcements): add information about announcement formatting in readme

* feat(announcements): bump packages versions for marked and dompurify

* feat(announcements): bump versions for marked and dompurify in package-lock.json

* fix(announcements): md to link was not working since the conflict merge

* fix(announcements): fix time before message and not after

* feat(announcements): past announcements add markdown support

* feat(announcements): static files
2025-11-08 13:28:57 -05:00
TwiN 7c6b5539c1 docs: Add past-announcements.jpg 2025-11-07 19:42:48 -05:00
TwiNandGitHub 607f3c5549 feat(announcements): Add support for archived announcements and add past announcement section in UI (#1382)
* feat(announcements): Add support for archived announcements and add past announcement section in UI

* Add missing field
2025-11-07 19:35:39 -05:00
Arden RasmussenandGitHub 9e97efaba1 fix(api): Escape endpoint key in URL for raw APIs (#1381) 2025-11-07 18:20:10 -05:00
diamanatandGitHub 8912b4b3e3 feat(client): Add support for monitoring gRPC endpoints (#1376)
* add grpc

* add gRPC to readme
2025-11-07 15:19:13 -05:00
5fdc489113 fix(client): update icmp/ping logic to determine pinger privileged mode (#1346)
* fix(pinger): update logic to determine pinger privileged mode

* add some unit tests for pinger

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* undo accidental removal

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* check for cap_net_raw by trying to open a raw socket and checking for permission error

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* revert syscall after testing. It is unable to build a binary on windows

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* remove extra import

* review icmp section of readme. No changes required

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* Update client/client.go

Co-authored-by: TwiN <twin@linux.com>

* Update client/client.go

Match function name

Co-authored-by: TwiN <twin@linux.com>

* Update client/client.go

Remove extra line

Co-authored-by: TwiN <twin@linux.com>

---------

Signed-off-by: Zee Aslam <zeet6613@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2025-11-04 20:42:20 -05:00
GiampaoloandGitHub 2ebb74ae1e docs: Add ECS fargate module in README (#1377) 2025-11-02 00:04:28 -04:00
TwiNandGitHub e2f06e9ede fix(ui): Modernize response time chart (#1373) 2025-10-29 14:15:59 -04:00
TwiNandGitHub beb9a2f3d9 feat(condition): Format certificate and domain expiration durations in human-readable format (#1370) 2025-10-27 16:30:12 -04:00
MaxandGitHub e469b6adf4 fix(external-endpoint): check per-endpoint maintenance windows (#1369)
* fix(external-endpoint): check per-endpoint maintenance windows

* refactor(external-endpoint): use tabs for indentation
2025-10-27 12:21:20 -04:00
2f8a3d2a02 feat(metrics): Add metrics for domain expiration (#1244)
* Add metrics for domain expiration

* Update grafana and prometheus versions and extend grafana dashboard with Domain expiration

* feat(deps) update whois version

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-25 12:45:15 -04:00
TwiNandGitHub 9495b7389e feat(ui): Add support for endpoints[].ui.hide-errors to hide errors (#1361)
Supersedes #1292
2025-10-22 13:13:53 -04:00
TwiN c8bdecbde8 docs: Fix ordering in Table of Contents 2025-10-21 14:11:00 -04:00
TwiNandGitHub 394602bc47 fix(ui): Ensure retrievedConfig is set to true after fetching configuration (#1359) 2025-10-21 08:06:02 -04:00
TwiNandGitHub 15813d4297 fix(client): Add nil check for SSHConfig before validating username/password (#1358)
Fixes #1357
2025-10-21 07:37:24 -04:00
TwiNandGitHub d24c66cf96 fix(key): Revert support for ( and ) as name/group, as they already worked before (#1356)
Relevant: #1340
2025-10-20 13:50:02 -04:00
TwiNandGitHub 70d7d0c54c fix(suites): Load persisted triggered alerts for suite endpoints on start (#1347) 2025-10-20 13:31:58 -04:00
TwiN 91931e48b4 fix(ui): Clear selected result before toggling new one
relevant: #1236
2025-10-17 22:06:54 -04:00
TwiN 386a4d2cb7 fix(ui): Implement toggleable tooltip for suites too
relevant: #1236
2025-10-17 21:41:40 -04:00
TwiN 4d9eb0572c docs(alerting): Link n8n-nodes-gatus-trigger for n8n alerting provider 2025-10-17 21:12:53 -04:00
1586b3cc0b feat(alerting): Add message-content parameter for Discord pings (#1335)
* feat(discord-alerts): add option for prefix-messages outside of embeds

* chore(docs): add discord prefix-message to README

* chore(discord-alerts): rename prefix-message to message-content

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-17 20:22:26 -04:00
981e082d0c feat(ui): Make tooltips toggleable (#1236)
* feat(results): allow for data points in checks to be "clicked"

asdf

* feat(ui): resolve merge conflicts

feat(dev): put back package.lock

* fix(ui): make sure the datapoint stays "fixed"

* fix(ui): watch for url changes to make tooltip go away

* feat(ui): add compiled app.css and app.js

* fix(ui): lengthen the tooltipElement name

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-17 16:09:47 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
91daaf92aa chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.8 to 2.52.9 (#1338)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.8 to 2.52.9.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.8...v2.52.9)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  dependency-version: 2.52.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:09:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a1bb07c556 chore(deps): bump golang.org/x/net from 0.45.0 to 0.46.0 (#1333)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/net/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:08:48 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
258175dec3 chore(deps): bump code.gitea.io/sdk/gitea from 0.21.0 to 0.22.0 (#1341)
Bumps code.gitea.io/sdk/gitea from 0.21.0 to 0.22.0.

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:08:31 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
ef6159e420 chore(deps): bump github.com/prometheus/client_golang from 1.23.0 to 1.23.2 (#1337)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.23.0 to 1.23.2.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.0...v1.23.2)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.23.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-10-16 20:45:36 -04:00
TwiNandGitHub ebd4068aac fix(key): Support (, ), + and & as name/group (#1340)
fix(key): Support (, ), + and & as name/group

Relevant: #1339
2025-10-16 16:47:11 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
39981de54b chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.43.0 (#1332)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 08:49:52 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7dce07e47f chore(deps): bump modernc.org/sqlite from 1.38.2 to 1.39.1 (#1331)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.38.2 to 1.39.1.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.38.2...v1.39.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 08:23:57 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6a83857db4 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.14.1 to 3.16.0 (#1313)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.14.1 to 3.16.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.14.1...v3.16.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 16:42:57 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
50702bd1d4 chore(deps): bump google.golang.org/api from 0.242.0 to 0.252.0 (#1315)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.242.0 to 0.252.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.242.0...v0.252.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.252.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 10:19:44 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5bf95fe4f7 chore(deps): bump github.com/valyala/fasthttp from 1.64.0 to 1.67.0 (#1330)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.64.0 to 1.67.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.64.0...v1.67.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 09:56:20 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
20d8ef966b chore(deps): bump golang.org/x/oauth2 from 0.30.0 to 0.32.0 (#1317)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.30.0 to 0.32.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.30.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 22:11:52 -04:00
michael-barabooandGitHub 8f15346fb7 fix(alerting)!: remove discontinued jetbrains space alerting provider (#1329)
remove alerting provider for discontinued jetbrains space
2025-10-13 20:47:05 -04:00
TwiNandGitHub 8920bdd301 fix(ui): Handle refresh properly on SuiteDetails.vue (#1324) 2025-10-12 14:27:43 -04:00
TwiN e37024dfc6 docs: Use working websocket example 2025-10-09 13:28:34 -04:00
TwiN ac4374b1e3 docs: Replace Docker Hub with GHCR as primary container registry 2025-10-06 14:15:14 -04:00
129fb82f71 feat(alerting): Add RESULT_CONDITIONS in custom alert to have more information (#1086)
feat(alerting): Add RESULT_CONDITIONS in custom alert to have more information on an alert while using custom alerting module

Add testing of new feature

Co-authored-by: TwiN <twin@linux.com>
2025-10-06 12:22:38 -04:00
Andrii OriekhovandGitHub 374be99b35 fix(alerting): Format link from Telegram alert description when sending message (#1200)
* allow passing Markdown link in telegram message

* update tests
2025-10-05 15:00:45 -04:00
5c78bd92fb feat(client): Support body placeholder for SSH endpoints (#1286)
* feat(ssh): Add BODY placeholder support for SSH endpoints

- Modify ExecuteSSHCommand to capture stdout output
- Update SSH endpoint handling to use needsToReadBody() mechanism
- Add comprehensive test cases for SSH BODY functionality
- Support basic body content, pattern matching, JSONPath, and functions
- Maintain backward compatibility with existing SSH endpoints

* docs: Add SSH BODY placeholder examples to README

- Add [BODY] placeholder to supported SSH placeholders list
- Add comprehensive examples showing various SSH BODY conditions
- Include pattern matching, length checks, JSONPath expressions
- Demonstrate function wrappers (len, has, any) usage

* Revert "docs: Add SSH BODY placeholder examples to README"

This reverts commit ae93e38683.

* docs: Add [BODY] placeholder to SSH supported placeholders list

* test: remove SSH BODY placeholder test cases

* Update client/client.go

* Update client/client.go

* docs: Add minimal SSH BODY example

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-03 22:52:34 -04:00
TwiNandGitHub 8853140cb2 feat(alerting): Add support for n8n alerts (#1309) 2025-10-03 16:51:26 -04:00
TwiNandGitHub 03ec18a703 fix(ui): Swap oldest/newest result time for SuiteCard.vue (#1308) 2025-10-03 13:36:09 -04:00
Kevin KuglerGitHubNerdySoftPawTwiNcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
65eaed4621 fix(incidentio): Implement deduplication key generation for alerts (#1296)
* fix(incidentio): Implement deduplication key generation for alerts

* fix(incidentio): Merge metadata from config and endpoint extra labels in request body

* fix(incidentio): Update comments for clarity and consistency in deduplication key generation and metadata merging

* fix(incidentio): Update comments for clarity and consistency in metadata merging and deduplication key generation

* fix(incidentio): Remove duplicate Metadata assignment in request body construction

* refactor(incidentio): Reformat code for consistency and readability in request body construction

* fix(incidentio): Remove unnecessary newline in buildRequestBody function

* Initial plan

* Fix incidentio tests to handle dynamic deduplication_key field

Co-authored-by: NerdySoftPaw <7468547+NerdySoftPaw@users.noreply.github.com>

---------

Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-10-03 13:33:51 -04:00
TwiNandGitHub 10c6e71eef chore(deps): Update aws-sdk-go to aws-sdk-go-v2 (#1305) 2025-10-03 13:33:37 -04:00
TwiN c7f0a32135 fix(tunneling): Adjust exponential backoff duration 2025-09-30 14:27:38 -04:00
TwiNandGitHub 405c15f756 fix(tunneling): Add exponential backoff retry (#1303) 2025-09-30 14:08:56 -04:00
TwiNandGitHub 6f1312dfcf chore: Tweak configuration validation and yaml output (#1302) 2025-09-30 13:38:17 -04:00
TwiNandGitHub bd296c75da chore: Export validation function (#1301) 2025-09-29 23:01:27 -04:00
TwiNandGitHub f007725140 fix(ui): Make sure EndpointCard aligns even if no group + hide-hostname (#1300) 2025-09-29 22:55:11 -04:00
TwiNandGitHub 40345a03d3 feat(client): Add support for SSH tunneling (#1298)
* feat(client): Add support for SSH tunneling

* Fix test
2025-09-28 14:26:12 -04:00
97a2be3504 fix(alerting): Added description block in teams-workflows (#1275)
* fix(alerting): Added description block in teams-workflows

* Update teamsworkflows_test.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-09-25 16:28:22 -04:00
TwiNandGitHub 15a4133502 fix(alerting): Limit minimum-reminder-interval to >5m (#1290) 2025-09-25 16:24:15 -04:00
RonandGitHub 64a5043655 docs(alerting): Remove SIGNL4 untested warning (#1289)
Update README.md

SIGNL4 warning removed. I have tested it and both, triggering and resolving of alerts work fine.
2025-09-24 06:33:57 -04:00
TwiNandGitHub 5a06a74cc3 fix(events): Retrieve newest events instead of oldest events (#1283)
Fixes #1040
2025-09-21 15:40:17 -04:00
TwiNandGitHub d6fa2c955b fix(suites): Handle invalid paths in store and update needsToReadBody to check store (#1282)
* fix(suites): Invalid path in store parameter should return an error

* Refactor

* fix(suites): Update needsToReadBody to check store mappings for body placeholders
2025-09-21 13:15:59 -04:00
mehdiMjandGitHub e6576e9080 fix(alerting): Support custom slack title (#1079) 2025-09-20 20:21:46 -04:00
TwiNandGitHub cd10b31ab5 fix(condition): Properly format conditions with invalid context placeholders (#1281) 2025-09-20 19:28:27 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d1ef0b72a4 chore(deps): bump golang.org/x/sync from 0.16.0 to 0.17.0 (#1269)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/sync/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-20 12:22:13 -04:00
TwiNandGitHub 327a39964d fix(security): Make OIDC session TTL configurable (#1280)
* fix(security): Increase session cookie from 1h to 8h

* fix(security): Make OIDC session TTL configurable

* revert accidental change
2025-09-20 07:29:25 -04:00
95 changed files with 4557 additions and 1563 deletions
@@ -16,4 +16,10 @@ endpoints:
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- "[STATUS] == 200"
- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
container_name: gatus
@@ -13,7 +12,7 @@ services:
prometheus:
container_name: prometheus
image: prom/prometheus:v2.14.0
image: prom/prometheus:v3.5.0
restart: always
command: --config.file=/etc/prometheus/prometheus.yml
ports:
@@ -25,7 +24,7 @@ services:
grafana:
container_name: grafana
image: grafana/grafana:6.4.4
image: grafana/grafana:12.1.0
restart: always
environment:
GF_SECURITY_ADMIN_PASSWORD: secret
@@ -19,7 +19,7 @@
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 566,
"id": 41,
"links": [],
"panels": [
{
@@ -39,7 +39,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "yellow",
@@ -79,7 +80,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -122,7 +123,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "yellow",
@@ -162,7 +164,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -193,7 +195,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
}
]
},
@@ -225,7 +228,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -292,7 +295,7 @@
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -321,7 +324,7 @@
"type": "prometheus",
"uid": "$datasource"
},
"description": "SSL certificate expiration times for all services",
"description": "Domain expiration times for all domains",
"fieldConfig": {
"defaults": {
"color": {
@@ -339,7 +342,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "#EAB839",
@@ -395,7 +399,137 @@
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"editorMode": "code",
"expr": "gatus_results_domain_expiration_seconds",
"format": "table",
"instant": true,
"legendFormat": "__auto",
"refId": "A"
}
],
"title": "Domain Expiration",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": false,
"__name__": true,
"app_kubernetes_io_instance": true,
"app_kubernetes_io_managed_by": true,
"app_kubernetes_io_name": true,
"app_kubernetes_io_service": true,
"helm_sh_chart": true,
"instance": true,
"job": true,
"key": true,
"type": true
},
"includeByName": {},
"indexByName": {
"Value": 2,
"group": 0,
"name": 1
},
"renameByName": {
"Value": "Time Until Expiry",
"group": "Group",
"name": "Service"
}
}
}
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "SSL certificate expiration times for all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "#EAB839",
"value": 172800
},
{
"color": "green",
"value": 604800
}
]
},
"unit": "dtdurations"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Time Until Expiry"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"applyToRow": false,
"type": "color-background"
}
},
{
"id": "unit",
"value": "s"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 11,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -444,6 +578,113 @@
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Current status distribution across all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(gatus_results_endpoint_success)",
"legendFormat": "Services UP",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(1 - gatus_results_endpoint_success)",
"legendFormat": "Services DOWN",
"refId": "B"
}
],
"title": "Service Status Distribution",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
@@ -483,7 +724,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "green",
@@ -525,7 +767,7 @@
"h": 8,
"w": 12,
"x": 12,
"y": 8
"y": 16
},
"id": 2,
"options": {
@@ -546,7 +788,7 @@
}
]
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -689,7 +931,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -703,9 +946,9 @@
},
"gridPos": {
"h": 8,
"w": 12,
"w": 24,
"x": 0,
"y": 16
"y": 24
},
"id": 4,
"options": {
@@ -721,7 +964,7 @@
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -735,112 +978,6 @@
],
"title": "Response Times",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Current status distribution across all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(gatus_results_endpoint_success)",
"legendFormat": "Services UP",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(1 - gatus_results_endpoint_success)",
"legendFormat": "Services DOWN",
"refId": "B"
}
],
"title": "Service Status Distribution",
"type": "timeseries"
}
],
"preload": false,
@@ -855,8 +992,8 @@
"list": [
{
"current": {
"text": "myprom",
"value": "PA04845DA3A4B088E"
"text": "prometheus",
"value": "cedv077q7bbwgd"
},
"description": "Select your Prometheus datasource",
"includeAll": false,
@@ -877,6 +1014,6 @@
"timepicker": {},
"timezone": "",
"title": "Gatus - Service Monitoring Dashboard",
"uid": "gatus-monitoring2",
"version": 10
}
"uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
"version": 1
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 89.76 89.75"><defs><style>.cls-1{fill:#3cad4b;}.cls-2{fill:#017400;}.cls-3{fill:#1e9025;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M33.67,65.35a23.35,23.35,0,0,1,.08-41,22.94,22.94,0,0,1,3.8-1.64A23,23,0,0,0,53.6,1C53,0,51,0,44.89,0c-9.08,0-9.21.17-8.81,3.22,1.07,8.12-9.42,12.5-14.45,6-1.94-2.52-2.1-2.52-8.68,4.16-6.22,6.3-6.33,6.28-3.77,8.25a8.09,8.09,0,0,1,2.56,9.53A8.15,8.15,0,0,1,3.08,36C0,35.63,0,35.73,0,45.2.08,53.81,0,54,3.3,53.63A8.06,8.06,0,0,1,9.76,67.52c-3,2.83-2.84,2.61,2.84,8.48,5.43,5.62,6.33,6.73,8.16,5.24L34,68A1.63,1.63,0,0,0,33.67,65.35Z"/><path class="cls-2" d="M85.43,36.13a8.11,8.11,0,0,1-5.27-14.21c2.85-2.5,2.82-2.37-3.55-8.75-4.31-4.31-5.71-5.75-6.87-5.4l-14,14a1.65,1.65,0,0,0,.36,2.61,23.35,23.35,0,0,1-.1,41,24.5,24.5,0,0,1-5.11,2c-8.54,2.28-14.73,9.63-14.73,18.47v1.27c.15,2.54,1.19,2.42,8.06,2.52,9.32.14,9.1.35,9.38-4.66a8.11,8.11,0,0,1,14-5.09c3,3.15,2.39,3.11,8.73-3.14,6.56-6.47,6.86-6.25,3.68-9.14a8.1,8.1,0,0,1,6.06-14.07c3.68.27,3.51.06,3.63-8.09C89.85,36.27,90,36.16,85.43,36.13Z"/><path class="cls-3" d="M41.11,59h8a.76.76,0,0,0,.77-.76V50.43a.76.76,0,0,1,.77-.76h7.84a.78.78,0,0,0,.77-.77V40.84a.77.77,0,0,0-.77-.76H50.7a.76.76,0,0,1-.77-.77V31.47a.76.76,0,0,0-.77-.77h-8a.76.76,0,0,0-.77.77v7.84a.76.76,0,0,1-.77.77H31.73a.77.77,0,0,0-.77.76V48.9a.78.78,0,0,0,.77.77h7.84a.76.76,0,0,1,.77.76v7.85A.76.76,0,0,0,41.11,59Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+297 -179
View File
@@ -20,13 +20,14 @@ _Looking for a managed solution? Check out [Gatus.io](https://gatus.io)._
<details>
<summary><b>Quick start</b></summary>
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
```
You can also use GitHub Container Registry if you prefer:
```console
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
```
You can also use Docker Hub if you prefer:
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
```
For more details, see [Usage](#usage)
</details>
@@ -49,8 +50,10 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Conditions](#conditions)
- [Placeholders](#placeholders)
- [Functions](#functions)
- [Announcements](#announcements)
- [Storage](#storage)
- [Client configuration](#client-configuration)
- [Tunneling](#tunneling)
- [Alerting](#alerting)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring Datadog alerts](#configuring-datadog-alerts)
@@ -65,11 +68,11 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring IFTTT alerts](#configuring-ifttt-alerts)
- [Configuring Ilert alerts](#configuring-ilert-alerts)
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
- [Configuring Line alerts](#configuring-line-alerts)
- [Configuring Matrix alerts](#configuring-matrix-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
- [Configuring n8n alerts](#configuring-n8n-alerts)
- [Configuring New Relic alerts](#configuring-new-relic-alerts)
- [Configuring Ntfy alerts](#configuring-ntfy-alerts)
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
@@ -93,7 +96,6 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert)
- [Announcements](#announcements)
- [Maintenance](#maintenance)
- [Security](#security)
- [Basic Authentication](#basic-authentication)
@@ -107,6 +109,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Docker](#docker)
- [Helm Chart](#helm-chart)
- [Terraform](#terraform)
- [Kubernetes](#kubernetes)
- [ECS Fargate](#ecs-fargate)
- [Running the tests](#running-the-tests)
- [Using in Production](#using-in-production)
- [FAQ](#faq)
@@ -117,6 +121,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)
- [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint)
- [Monitoring a WebSocket endpoint](#monitoring-a-websocket-endpoint)
- [Monitoring an endpoint using gRPC](#monitoring-an-endpoint-using-grpc)
- [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp)
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
@@ -184,18 +189,15 @@ The main features of Gatus are:
## Usage
<details>
<summary><b>Quick start</b></summary>
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
```
You can also use GitHub Container Registry if you prefer:
You can also use Docker Hub if you prefer:
```console
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
```
If you want to create your own configuration, see [Docker](#docker) for information on how to mount a configuration file.
</details>
Here's a simple example:
```yaml
@@ -310,6 +312,7 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname from the results. | `false` |
| `endpoints[].ui.hide-port` | Whether to hide the port from the results. | `false` |
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
| `endpoints[].ui.hide-errors` | Whether to hide errors from the results. | `false` |
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
| `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` |
@@ -367,17 +370,17 @@ or send an HTTP request:
POST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
- Using the example configuration above, the key would be `core_ext-ep-test`.
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
- `{error}` (optional): a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful.
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
You must also pass the token as a `Bearer` token in the `Authorization` header.
### Suites (ALPHA)
Suites are collections of endpoints that are executed sequentially with a shared context.
Suites are collections of endpoints that are executed sequentially with a shared context.
This allows you to create complex monitoring scenarios where the result from one endpoint can be used in subsequent endpoints, enabling workflow-style monitoring.
Here are a few cases in which suites could be useful:
@@ -419,7 +422,7 @@ suites:
context:
price: "19.99" # Initial static value in context
endpoints:
# Step 1: Create an item and store the item ID
# Step 1: Create an item and store the item ID
- name: create-item
url: https://api.example.com/items
method: POST
@@ -433,7 +436,7 @@ suites:
alerts:
- type: slack
description: "Failed to create item"
# Step 2: Update the item using the stored item ID
- name: update-item
url: https://api.example.com/items/[CONTEXT].itemId
@@ -444,7 +447,7 @@ suites:
alerts:
- type: slack
description: "Failed to update item"
# Step 3: Fetch the item and validate the price
- name: get-item
url: https://api.example.com/items/[CONTEXT].itemId
@@ -455,7 +458,7 @@ suites:
alerts:
- type: slack
description: "Item price did not update correctly"
# Step 4: Delete the item (always-run: true to ensure cleanup even if step 2 or 3 fails)
- name: delete-item
url: https://api.example.com/items/[CONTEXT].itemId
@@ -523,18 +526,21 @@ Here are some examples of conditions you can use:
### Announcements
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information.
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information. You can use markdown to format your announcements.
| Parameter | Description | Default |
|:----------------------------|:----------------------------------------------------------------------------------------------|:---------|
| `announcements` | List of announcements to display | `[]` |
| `announcements[].timestamp` | UTC timestamp when the announcement was made (RFC3339 format) | Required |
| `announcements[].type` | Type of announcement. Valid values: `outage`, `warning`, `information`, `operational`, `none` | `"none"` |
| `announcements[].message` | The message to display to users | Required |
This is essentially what some status page calls "incident communications".
| Parameter | Description | Default |
|:----------------------------|:-------------------------------------------------------------------------------------------------------------------------|:---------|
| `announcements` | List of announcements to display | `[]` |
| `announcements[].timestamp` | UTC timestamp when the announcement was made (RFC3339 format) | Required |
| `announcements[].type` | Type of announcement. Valid values: `outage`, `warning`, `information`, `operational`, `none` | `"none"` |
| `announcements[].message` | The message to display to users | Required |
| `announcements[].archived` | Whether to archive the announcement. Archived announcements show at the bottom of the status page instead of at the top. | `false` |
Types:
- **outage**: Indicates service disruptions or critical issues (red theme)
- **warning**: Indicates potential issues or important notices (yellow theme)
- **warning**: Indicates potential issues or important notices (yellow theme)
- **information**: General information or updates (blue theme)
- **operational**: Indicates resolved issues or normal operations (green theme)
- **none**: Neutral announcements with no specific severity (gray theme, default if none are specified)
@@ -542,17 +548,24 @@ Types:
Example Configuration:
```yaml
announcements:
- timestamp: 2025-08-15T14:00:00Z
- timestamp: 2025-11-07T14:00:00Z
type: outage
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
- timestamp: 2025-08-15T16:15:00Z
type: operational
- timestamp: 2025-11-07T16:15:00Z
type: operational
message: "Database maintenance completed successfully. All systems operational."
- timestamp: 2025-08-15T12:00:00Z
- timestamp: 2025-11-07T12:00:00Z
type: information
message: "New monitoring dashboard features will be deployed next week"
- timestamp: 2025-11-06T09:00:00Z
type: warning
message: "Elevated API response times observed for US customers"
archived: true
```
If at least one announcement is archived, a **Past Announcements** section will be rendered at the bottom of the status page:
![Gatus past announcements section](.github/assets/past-announcements.jpg)
### Storage
| Parameter | Description | Default |
@@ -597,24 +610,25 @@ See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres
In order to support a wide range of environments, each monitored endpoint has a unique configuration for
the client used to send the request.
| Parameter | Description | Default |
|:---------------------------------------|:----------------------------------------------------------------------------|:----------------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` |
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
| `client.oauth2` | OAuth2 client configuration. | `{}` |
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
| `client.proxy-url` | The URL of the proxy to use for the client | `""` |
| `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` |
| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` |
| `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` |
| `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` |
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
| Parameter | Description | Default |
|:---------------------------------------|:------------------------------------------------------------------------------|:----------------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` |
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
| `client.oauth2` | OAuth2 client configuration. | `{}` |
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
| `client.proxy-url` | The URL of the proxy to use for the client | `""` |
| `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` |
| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` |
| `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` |
| `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` |
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
| `client.tunnel` | Name of the SSH tunnel to use for this endpoint. See [Tunneling](#tunneling). | `""` |
> 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
@@ -705,23 +719,65 @@ endpoints:
> 📝 Note that if running in a container, you must volume mount the certificate and key into the container.
### Tunneling
Gatus supports SSH tunneling to monitor internal services through jump hosts or bastion servers.
This is particularly useful for monitoring services that are not directly accessible from where Gatus is deployed.
SSH tunnels are defined globally in the `tunneling` section and then referenced by name in endpoint client configurations.
| Parameter | Description | Default |
|:--------------------------------------|:------------------------------------------------------------|:--------------|
| `tunneling` | SSH tunnel configurations | `{}` |
| `tunneling.<tunnel-name>` | Configuration for a named SSH tunnel | `{}` |
| `tunneling.<tunnel-name>.type` | Type of tunnel (currently only `SSH` is supported) | Required `""` |
| `tunneling.<tunnel-name>.host` | SSH server hostname or IP address | Required `""` |
| `tunneling.<tunnel-name>.port` | SSH server port | `22` |
| `tunneling.<tunnel-name>.username` | SSH username | Required `""` |
| `tunneling.<tunnel-name>.password` | SSH password (use either this or private-key) | `""` |
| `tunneling.<tunnel-name>.private-key` | SSH private key in PEM format (use either this or password) | `""` |
| `client.tunnel` | Name of the tunnel to use for this endpoint | `""` |
```yaml
tunneling:
production:
type: SSH
host: "jumphost.example.com"
username: "monitoring"
private-key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----
endpoints:
- name: "internal-api"
url: "http://internal-api.example.com:8080/health"
client:
tunnel: "production"
conditions:
- "[STATUS] == 200"
```
> ⚠️ **WARNING**:: Tunneling may introduce additional latency, especially if the connection to the tunnel is retried frequently.
> This may lead to inaccurate response time measurements.
### Alerting
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
individual endpoints with configurable descriptions and thresholds.
Alerts are configured at the endpoint level like so:
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `alerts` | List of all alerts for a given endpoint. | `[]` |
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
| `alerts[].enabled` | Whether to enable the alert. | `true` |
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerts[].minimum-reminder-interval` | Minimum time interval between alert reminders. E.g. `"30m"`, `"1h45m30s"` or `"24h"`. If empty or `0`, reminders are disabled. | `0` |
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
| Parameter | Description | Default |
|:-------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `alerts` | List of all alerts for a given endpoint. | `[]` |
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
| `alerts[].enabled` | Whether to enable the alert. | `true` |
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerts[].minimum-reminder-interval` | Minimum time interval between alert reminders. E.g. `"30m"`, `"1h45m30s"` or `"24h"`. If empty or `0`, reminders are disabled. Cannot be lower than `5m`. | `0` |
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
Here's an example of what an alert configuration might look like at the endpoint level:
```yaml
@@ -768,11 +824,11 @@ endpoints:
| `alerting.ifttt` | Configuration for alerts of type `ifttt`. <br />See [Configuring IFTTT alerts](#configuring-ifttt-alerts). | `{}` |
| `alerting.ilert` | Configuration for alerts of type `ilert`. <br />See [Configuring ilert alerts](#configuring-ilert-alerts). | `{}` |
| `alerting.incident-io` | Configuration for alerts of type `incident-io`. <br />See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` |
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
| `alerting.line` | Configuration for alerts of type `line`. <br />See [Configuring Line alerts](#configuring-line-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.n8n` | Configuration for alerts of type `n8n`. <br />See [Configuring n8n alerts](#configuring-n8n-alerts). | `{}` |
| `alerting.newrelic` | Configuration for alerts of type `newrelic`. <br />See [Configuring New Relic alerts](#configuring-new-relic-alerts). | `{}` |
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
@@ -878,6 +934,7 @@ endpoints:
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
| `alerting.discord.message-content` | Message content to send before the embed (useful for pinging users/roles, e.g. `<@123>`) | `""` |
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
@@ -1257,7 +1314,7 @@ endpoints:
```
#### Configuring ilert alerts
#### Configuring Ilert alerts
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
@@ -1339,42 +1396,6 @@ In order to get the required alert source config id and authentication token, yo
> **_NOTE:_** the source config id is of the form `https://api.incident.io/v2/alert_events/http/$ID` and the token is expected to be passed as a bearer token like so: `Authorization: Bearer $TOKEN`
#### Configuring JetBrains Space alerts
| Parameter | Description | Default |
|:--------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace` | `{}` |
| `alerting.jetbrainsspace.project` | JetBrains Space project name | Required `""` |
| `alerting.jetbrainsspace.channel-id` | JetBrains Space Chat Channel ID | Required `""` |
| `alerting.jetbrainsspace.token` | Token that is used for authentication. | Required `""` |
| `alerting.jetbrainsspace.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.jetbrainsspace.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.jetbrainsspace.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.jetbrainsspace.overrides[].*` | See `alerting.jetbrainsspace.*` parameters | `{}` |
```yaml
alerting:
jetbrainsspace:
project: myproject
channel-id: ABCDE12345
token: "**************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: jetbrainsspace
description: "healthcheck failed"
send-on-resolved: true
```
Here's an example of what the notifications look like:
![JetBrains Space notifications](.github/assets/jetbrains-space-alerts.png)
#### Configuring Line alerts
| Parameter | Description | Default |
@@ -1535,8 +1556,8 @@ alerting:
region: "US" # or "EU" for European region
endpoints:
- name: website
url: "https://twin.sh/health"
- name: example
url: "https://example.org"
interval: 5m
conditions:
- "[STATUS] == 200"
@@ -1546,6 +1567,52 @@ endpoints:
```
#### Configuring n8n alerts
| Parameter | Description | Default |
|:---------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.n8n` | Configuration for alerts of type `n8n` | `{}` |
| `alerting.n8n.webhook-url` | n8n webhook URL | Required `""` |
| `alerting.n8n.title` | Title of the alert sent to n8n | `""` |
| `alerting.n8n.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.n8n.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.n8n.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.n8n.overrides[].*` | See `alerting.n8n.*` parameters | `{}` |
[n8n](https://n8n.io/) is a workflow automation platform that allows you to automate tasks across different applications and services using webhooks.
See [n8n-nodes-gatus-trigger](https://github.com/TwiN/n8n-nodes-gatus-trigger) for a n8n community node that can be used as trigger.
Example:
```yaml
alerting:
n8n:
webhook-url: "https://your-n8n-instance.com/webhook/your-webhook-id"
title: "Gatus Monitoring"
default-alert:
send-on-resolved: true
endpoints:
- name: example
url: "https://example.org"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: n8n
description: "Health check alert"
```
The JSON payload sent to the n8n webhook will include:
- `title`: The configured title
- `endpoint_name`: Name of the endpoint
- `endpoint_group`: Group of the endpoint (if any)
- `endpoint_url`: URL being monitored
- `alert_description`: Custom alert description
- `resolved`: Boolean indicating if the alert is resolved
- `message`: Human-readable alert message
- `condition_results`: Array of condition results with their success status
#### Configuring Ntfy alerts
| Parameter | Description | Default |
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
@@ -1799,6 +1866,40 @@ endpoints:
```
#### Configuring SendGrid alerts
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
| Parameter | Description | Default |
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.sendgrid` | Configuration for alerts of type `sendgrid` | `{}` |
| `alerting.sendgrid.api-key` | SendGrid API key | Required `""` |
| `alerting.sendgrid.from` | Email address to send from | Required `""` |
| `alerting.sendgrid.to` | Email address(es) to send alerts to (comma-separated for multiple recipients) | Required `""` |
| `alerting.sendgrid.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.sendgrid.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.sendgrid.overrides[].*` | See `alerting.sendgrid.*` parameters | `{}` |
```yaml
alerting:
sendgrid:
api-key: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
from: "alerts@example.com"
to: "admin@example.com,ops@example.com"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: sendgrid
send-on-resolved: true
```
#### Configuring Signal alerts
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
@@ -1837,8 +1938,6 @@ endpoints:
#### Configuring SIGNL4 alerts
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
SIGNL4 is a mobile alerting and incident management service that sends critical alerts to team members via mobile push, SMS, voice calls, and email.
| Parameter | Description | Default |
@@ -1867,49 +1966,16 @@ endpoints:
```
#### Configuring SendGrid alerts
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
| Parameter | Description | Default |
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.sendgrid` | Configuration for alerts of type `sendgrid` | `{}` |
| `alerting.sendgrid.api-key` | SendGrid API key | Required `""` |
| `alerting.sendgrid.from` | Email address to send from | Required `""` |
| `alerting.sendgrid.to` | Email address(es) to send alerts to (comma-separated for multiple recipients) | Required `""` |
| `alerting.sendgrid.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.sendgrid.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.sendgrid.overrides[].*` | See `alerting.sendgrid.*` parameters | `{}` |
```yaml
alerting:
sendgrid:
api-key: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
from: "alerts@example.com"
to: "admin@example.com,ops@example.com"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: sendgrid
send-on-resolved: true
```
#### Configuring Slack alerts
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
```yaml
alerting:
@@ -2127,7 +2193,7 @@ Here's an example of what the notifications look like:
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` |
| `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` |
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
@@ -2246,14 +2312,14 @@ endpoints:
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
| Parameter | Description | Default |
|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.webex` | Configuration for alerts of type `webex` | `{}` |
| `alerting.webex.webhook-url` | Webex Teams webhook URL | Required `""` |
| `alerting.webex.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.webex.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.webex.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.webex.overrides[].*` | See `alerting.webex.*` parameters | `{}` |
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.webex` | Configuration for alerts of type `webex` | `{}` |
| `alerting.webex.webhook-url` | Webex Teams webhook URL | Required `""` |
| `alerting.webex.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.webex.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.webex.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.webex.overrides[].*` | See `alerting.webex.*` parameters | `{}` |
```yaml
alerting:
@@ -2305,7 +2371,7 @@ endpoints:
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|
| `alerting.zulip` | Configuration for alerts of type `zulip` | `{}` |
| `alerting.zulip` | Configuration for alerts of type `zulip` | `{}` |
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
@@ -2361,7 +2427,8 @@ Furthermore, you may use the following placeholders in the body (`alerting.custo
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
- `[RESULT_ERRORS]` (resolved from the health evaluation of a given health check)
- `[RESULT_CONDITIONS]` (condition results from the health evaluation of a given health check)
-
If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the
`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.
The aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified
@@ -2579,6 +2646,7 @@ security:
| `security.oidc.client-secret` | Client secret | Required `""` |
| `security.oidc.scopes` | Scopes to request. The only scope you need is `openid`. | Required `[]` |
| `security.oidc.allowed-subjects` | List of subjects to allow. If empty, all subjects are allowed. | `[]` |
| `security.oidc.session-ttl` | Session time-to-live (e.g. `8h`, `1h30m`, `2h`). | `8h` |
```yaml
security:
@@ -2590,6 +2658,8 @@ security:
scopes: ["openid"]
# You may optionally specify a list of allowed subjects. If this is not specified, all subjects will be allowed.
#allowed-subjects: ["johndoe@example.com"]
# You may optionally specify a session time-to-live. If this is not specified, defaults to 8 hours.
#session-ttl: 8h
```
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).
@@ -2619,6 +2689,7 @@ endpoint on the same port your application is configured to run on (`web.port`).
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
| gatus_results_domain_expiration_seconds | gauge | Number of seconds until the domains expires | key, group, name, type | HTTP, STARTTLS |
| gatus_results_endpoint_success | gauge | Displays whether or not the endpoint was a success (0 failure, 1 success) | key, group, name, type | All |
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
@@ -2696,24 +2767,24 @@ Many examples can be found in the [.examples](.examples) folder, but this sectio
### Docker
To run Gatus locally with Docker:
```console
docker run -p 8080:8080 --name gatus twinproduction/gatus
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
```
Other than using one of the examples provided in the [.examples](.examples) folder, you can also try it out locally by
creating a configuration file, we'll call it `config.yaml` for this example, and running the following
command:
```console
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
docker run -p 8080:8080 --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml --name gatus ghcr.io/twin/gatus:stable
```
If you're on Windows, replace `"$(pwd)"` by the absolute path to your current directory, e.g.:
```console
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus twinproduction/gatus
docker run -p 8080:8080 --mount type=bind,source=C:/Users/Chris/Desktop/config.yaml,target=/config/config.yaml --name gatus ghcr.io/twin/gatus:stable
```
To build the image locally:
```console
docker build . -t twinproduction/gatus
docker build . -t ghcr.io/twin/gatus:stable
```
@@ -2733,8 +2804,14 @@ To get more details, please check [chart's configuration](https://github.com/Twi
### Terraform
Gatus can be deployed on Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
#### Kubernetes
Gatus can be deployed on Kubernetes using Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
#### ECS Fargate
Gatus can be deployed on ECS Fargate using Terraform by using the following module: [terraform-aws-gatus-ecs](https://github.com/GiamPy5/terraform-aws-gatus-ecs).
## Running the tests
```console
@@ -2778,7 +2855,7 @@ will send a `POST` request to `http://localhost:8080/playground` with the follow
### Recommended interval
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus limits the number of
To ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus limits the number of
endpoints/suites that can be evaluated at the same time.
In other words, even if you have multiple endpoints with the same interval, they are not guaranteed to run at the same time.
@@ -2874,20 +2951,59 @@ This works for SCTP based application.
### Monitoring a WebSocket endpoint
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level:
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints:
```yaml
endpoints:
- name: example
url: "wss://example.com/"
url: "wss://echo.websocket.org/"
body: "status"
conditions:
- "[CONNECTED] == true"
- "[BODY].result >= 0"
- "[BODY] == pat(*served by*)"
```
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
shows whether the connection was successfully established. You can use Go template
syntax. The functions LocalAddr and RandomString with a length can be used.
shows whether the connection was successfully established. You can use Go template
syntax.
### Monitoring an endpoint using gRPC
You can monitor gRPC services by prefixing `endpoints[].url` with `grpc://` or `grpcs://`.
Gatus executes the standard `grpc.health.v1.Health/Check` RPC against the target.
```yaml
endpoints:
- name: my-grpc
url: grpc://localhost:50051
interval: 30s
conditions:
- "[CONNECTED] == true"
- "[BODY].status == SERVING" # BODY is read only when referenced
client:
timeout: 5s
```
For TLS-enabled servers, use `grpcs://` and configure client TLS if necessary:
```yaml
endpoints:
- name: my-grpcs
url: grpcs://example.com:443
conditions:
- "[CONNECTED] == true"
- "[BODY].status == SERVING"
client:
timeout: 5s
insecure: false # set true to skip cert verification (not recommended)
tls:
certificate-file: /path/to/cert.pem # optional mTLS client cert
private-key-file: /path/to/key.pem # optional mTLS client key
```
Notes:
- The health check targets the default service (`service: ""`). Support for a custom service name can be added later if needed.
- The response body is exposed as a minimal JSON object like `{"status":"SERVING"}` only when required by conditions or suite store mappings.
- Timeouts, custom DNS resolvers and SSH tunnels are honored via the existing [`client` configuration](#client-configuration).
### Monitoring an endpoint using ICMP
@@ -2904,7 +3020,7 @@ endpoints:
Only the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for endpoints of type ICMP.
You can specify a domain prefixed by `icmp://`, or an IP address prefixed by `icmp://`.
If you run Gatus on Linux, please read the Linux section on https://github.com/prometheus-community/pro-bing#linux
If you run Gatus on Linux, please read the Linux section on [https://github.com/prometheus-community/pro-bing#linux]
if you encounter any problems.
@@ -2939,12 +3055,13 @@ endpoints:
password: "password"
body: |
{
"command": "uptime"
"command": "echo '{\"memory\": {\"used\": 512}}'"
}
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
- "[BODY].memory.used > 500"
```
you can also use no authentication to monitor the endpoint by not specifying the username
@@ -2967,6 +3084,7 @@ endpoints:
The following placeholders are supported for endpoints of type SSH:
- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
- `[BODY]` resolves to the stdout output of the command executed on the remote server
- `[IP]` resolves to the IP address of the server
- `[RESPONSE_TIME]` resolves to the time it took to establish the connection and execute the command
@@ -3020,7 +3138,7 @@ endpoints:
- "[CERTIFICATE_EXPIRATION] > 240h"
```
> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to use RDAP, or as a fallback, send a request to the official IANA WHOIS service
> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to use RDAP, or as a fallback, send a request to the official IANA WHOIS service
> [through a library](https://github.com/TwiN/whois) and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`).
> To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from
> using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`.
@@ -3049,7 +3167,7 @@ concurrency: 0
**Use cases for higher concurrency:**
- You have a large number of endpoints to monitor
- You want to monitor endpoints at very short intervals (< 5s)
- You want to monitor endpoints at very short intervals (< 5s)
- You're using Gatus for load testing scenarios
**Legacy configuration:**
@@ -3133,7 +3251,7 @@ ui:
default-sort-by: group
```
Note that if a user has already sorted the dashboard by a different field, the default sort will not be applied unless the user
clears their browser's localstorage.
clears their browser's localstorage.
### Exposing Gatus on a custom path
@@ -3239,7 +3357,7 @@ The path to generate a badge is the following:
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
the URL would look like this:
@@ -3265,7 +3383,7 @@ The path to generate a badge is the following:
/api/v1/endpoints/{key}/health/badge.svg
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
the URL would look like this:
@@ -3282,7 +3400,7 @@ The path to generate a badge is the following:
/api/v1/endpoints/{key}/health/badge.shields
```
Where:
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
the URL would look like this:
@@ -3305,7 +3423,7 @@ The endpoint to generate a badge is the following:
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
#### Response time (chart)
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/chart.svg)
@@ -3318,7 +3436,7 @@ The endpoint to generate a response time chart is the following:
```
Where:
- `{duration}` is `30d`, `7d`, or `24h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
##### How to change the color thresholds of the response time badge
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
@@ -3376,7 +3494,7 @@ The path to get raw uptime data for an endpoint is:
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the raw uptime data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
```
@@ -3390,7 +3508,7 @@ The path to get raw response time data for an endpoint is:
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
For instance, if you want the raw response time data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
```
+5
View File
@@ -15,6 +15,8 @@ import (
var (
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
ErrAlertWithInvalidMinimumReminderInterval = errors.New("minimum-reminder-interval must be either omitted or be at least 5m")
)
// Alert is endpoint.Endpoint's alert configuration
@@ -78,6 +80,9 @@ func (alert *Alert) ValidateAndSetDefaults() error {
if alert.SuccessThreshold <= 0 {
alert.SuccessThreshold = 2
}
if alert.MinimumReminderInterval != 0 && alert.MinimumReminderInterval < 5*time.Minute {
return ErrAlertWithInvalidMinimumReminderInterval
}
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
return ErrAlertWithInvalidDescription
}
+56
View File
@@ -3,6 +3,7 @@ package alert
import (
"errors"
"testing"
"time"
)
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
@@ -36,6 +37,61 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "valid-minimum-reminder-interval-0",
alert: Alert{
MinimumReminderInterval: 0,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: nil,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "valid-minimum-reminder-interval-5m",
alert: Alert{
MinimumReminderInterval: 5 * time.Minute,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: nil,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "valid-minimum-reminder-interval-10m",
alert: Alert{
MinimumReminderInterval: 10 * time.Minute,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: nil,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "invalid-minimum-reminder-interval-1m",
alert: Alert{
MinimumReminderInterval: 1 * time.Minute,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "invalid-minimum-reminder-interval-1s",
alert: Alert{
MinimumReminderInterval: 1 * time.Second,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
+3 -3
View File
@@ -47,9 +47,6 @@ const (
// TypeIncidentIO is the Type for the incident-io alerting provider
TypeIncidentIO Type = "incident-io"
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
TypeJetBrainsSpace Type = "jetbrainsspace"
// TypeLine is the Type for the line alerting provider
TypeLine Type = "line"
@@ -65,6 +62,9 @@ const (
// TypeNewRelic is the Type for the newrelic alerting provider
TypeNewRelic Type = "newrelic"
// TypeN8N is the Type for the n8n alerting provider
TypeN8N Type = "n8n"
// TypeNtfy is the Type for the ntfy alerting provider
TypeNtfy Type = "ntfy"
+6 -7
View File
@@ -20,11 +20,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
@@ -66,7 +66,6 @@ type Config struct {
// Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"`
// GitHub is the configuration for the github alerting provider
GitHub *github.AlertProvider `yaml:"github,omitempty"`
@@ -81,22 +80,19 @@ type Config struct {
// Gotify is the configuration for the gotify alerting provider
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
// HomeAssistant is the configuration for the homeassistant alerting provider
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
// IFTTT is the configuration for the ifttt alerting provider
IFTTT *ifttt.AlertProvider `yaml:"ifttt,omitempty"`
// Ilert is the configuration for the ilert alerting provider
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
// IncidentIO is the configuration for the incident-io alerting provider
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
// Line is the configuration for the line alerting provider
Line *line.AlertProvider `yaml:"line,omitempty"`
@@ -112,6 +108,9 @@ type Config struct {
// NewRelic is the configuration for the newrelic alerting provider
NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
// N8N is the configuration for the n8n alerting provider
N8N *n8n.AlertProvider `yaml:"n8n,omitempty"`
// Ntfy is the configuration for the ntfy alerting provider
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
+25 -38
View File
@@ -1,18 +1,18 @@
package awsses
import (
"context"
"errors"
"fmt"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/ses"
"github.com/aws/aws-sdk-go-v2/service/ses/types"
"gopkg.in/yaml.v3"
)
@@ -102,63 +102,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if err != nil {
return err
}
awsSession, err := provider.createSession(cfg)
ctx := context.Background()
svc, err := provider.createClient(ctx, cfg)
if err != nil {
return err
}
svc := ses.New(awsSession)
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(cfg.To, ",")
input := &ses.SendEmailInput{
Destination: &ses.Destination{
ToAddresses: aws.StringSlice(emails),
Destination: &types.Destination{
ToAddresses: emails,
},
Message: &ses.Message{
Body: &ses.Body{
Text: &ses.Content{
Message: &types.Message{
Body: &types.Body{
Text: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(body),
},
},
Subject: &ses.Content{
Subject: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(subject),
},
},
Source: aws.String(cfg.From),
}
if _, err = svc.SendEmail(input); err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case ses.ErrCodeMessageRejected:
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException:
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException:
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
default:
logr.Error(aerr.Error())
}
} else {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
logr.Error(err.Error())
}
if _, err = svc.SendEmail(ctx, input); err != nil {
return err
}
return nil
}
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
awsConfig := &aws.Config{
Region: aws.String(cfg.Region),
func (provider *AlertProvider) createClient(ctx context.Context, cfg *Config) (*ses.Client, error) {
var opts []func(*config.LoadOptions) error
if len(cfg.Region) > 0 {
opts = append(opts, config.WithRegion(cfg.Region))
}
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
opts = append(opts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")))
}
return session.NewSession(awsConfig)
awsConfig, err := config.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, err
}
return ses.NewFromConfig(awsConfig), nil
}
// buildMessageSubjectAndBody builds the message subject and body
+19
View File
@@ -111,6 +111,25 @@ func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoi
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
if len(result.ConditionResults) > 0 && strings.Contains(body, "[RESULT_CONDITIONS]") {
var formattedConditionResults string
for index, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`", prefix, conditionResult.Condition)
if index < len(result.ConditionResults)-1 {
formattedConditionResults += ", "
}
}
body = strings.ReplaceAll(body, "[RESULT_CONDITIONS]", formattedConditionResults)
url = strings.ReplaceAll(url, "[RESULT_CONDITIONS]", formattedConditionResults)
}
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
+63
View File
@@ -261,6 +261,69 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
}
}
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholderAndResultConditions(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_CONDITIONS]",
Headers: nil,
Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "fixed",
"TRIGGERED": "boom",
},
},
},
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
NoConditions bool
}{
{
AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed,✅ - `[CONNECTED] == true`, ✅ - `[STATUS] == 200`",
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom,❌ - `[CONNECTED] == true`, ❌ - `[STATUS] == 200`",
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
}
}
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
+7 -3
View File
@@ -20,8 +20,9 @@ var (
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
MessageContent string `yaml:"message-content,omitempty"` // Message content for pinging users or groups (e.g. "<@123456789>" or "<@&987654321>")
}
func (cfg *Config) Validate() error {
@@ -38,6 +39,9 @@ func (cfg *Config) Merge(override *Config) {
if len(override.Title) > 0 {
cfg.Title = override.Title
}
if len(override.MessageContent) > 0 {
cfg.MessageContent = override.MessageContent
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord
@@ -142,7 +146,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
title = cfg.Title
}
body := Body{
Content: "",
Content: cfg.MessageContent,
Embeds: []Embed{
{
Title: title,
+67
View File
@@ -134,6 +134,16 @@ func TestAlertProvider_Send(t *testing.T) {
}),
ExpectedError: false,
},
{
Name: "triggered-with-message-content",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -200,6 +210,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: false,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
},
{
Name: "triggered-with-message-content-user-mention",
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
Name: "triggered-with-message-content-role-mention",
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@&987654321>"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"content\":\"\\u003c@\\u0026987654321\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
Name: "resolved-with-message-content",
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -313,6 +344,39 @@ func TestAlertProvider_GetConfig(t *testing.T) {
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
{
Name: "provider-with-message-content-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
},
{
Name: "provider-with-message-content-group-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
},
{
Name: "provider-with-message-content-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"message-content": "<@999999999>"}},
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@999999999>"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -323,6 +387,9 @@ func TestAlertProvider_GetConfig(t *testing.T) {
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
if got.MessageContent != scenario.ExpectedOutput.MessageContent {
t.Errorf("expected message content to be %s, got %s", scenario.ExpectedOutput.MessageContent, got.MessageContent)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
+18
View File
@@ -0,0 +1,18 @@
package incidentio
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// generateDeduplicationKey generates a unique deduplication_key for incident.io
func generateDeduplicationKey(ep *endpoint.Endpoint, alert *alert.Alert) string {
data := fmt.Sprintf("%s|%s|%s|%d", ep.Key(), alert.Type, alert.GetDescription(), time.Now().UnixNano())
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}
+22 -5
View File
@@ -153,27 +153,44 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
} else {
prefix = "🔴"
}
// No need for \n since incident.io trims it anyways.
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
var body []byte
// Generate deduplication key if empty (first firing)
if alert.ResolveKey == "" {
// Generate unique key (endpoint key, alert type, timestamp)
alert.ResolveKey = generateDeduplicationKey(ep, alert)
}
// Extract alert_source_config_id from URL
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
body, _ = json.Marshal(Body{
// Merge metadata: cfg.Metadata + ep.ExtraLabels (if present)
mergedMetadata := map[string]interface{}{}
// Copy cfg.Metadata
for k, v := range cfg.Metadata {
mergedMetadata[k] = v
}
// Add extra labels from endpoint (if present)
if ep.ExtraLabels != nil && len(ep.ExtraLabels) > 0 {
for k, v := range ep.ExtraLabels {
mergedMetadata[k] = v
}
}
body, _ := json.Marshal(Body{
AlertSourceConfigID: alertSourceID,
Title: "Gatus: " + ep.DisplayName(),
Status: status,
DeduplicationKey: alert.ResolveKey,
Description: message,
SourceURL: cfg.SourceURL,
Metadata: cfg.Metadata,
Metadata: mergedMetadata,
})
fmt.Printf("%v", string(body))
return body
}
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
+83 -30
View File
@@ -183,39 +183,63 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
secondDescription := "description-2"
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedAlertSourceID string
ExpectedStatus string
ExpectedTitle string
ExpectedDescription string
ExpectedSourceURL string
ExpectedMetadata map[string]interface{}
ShouldHaveDeduplicationKey bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedAlertSourceID: "some-id",
ExpectedStatus: "firing",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
ShouldHaveDeduplicationKey: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedAlertSourceID: "some-id",
ExpectedStatus: "resolved",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
ShouldHaveDeduplicationKey: true,
},
{
Name: "resolved-with-metadata-source-url",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
Name: "resolved-with-metadata-source-url",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedAlertSourceID: "some-id",
ExpectedStatus: "resolved",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
ExpectedSourceURL: "some-source-url",
ExpectedMetadata: map[string]interface{}{"service": "some-service", "team": "very-core"},
ShouldHaveDeduplicationKey: true,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedAlertSourceID: "different-id",
ExpectedStatus: "firing",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
ShouldHaveDeduplicationKey: true,
},
}
@@ -237,13 +261,42 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
// Parse the JSON body
var parsedBody Body
if err := json.Unmarshal(body, &parsedBody); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
// Validate individual fields
if parsedBody.AlertSourceConfigID != scenario.ExpectedAlertSourceID {
t.Errorf("expected alert_source_config_id to be %s, got %s", scenario.ExpectedAlertSourceID, parsedBody.AlertSourceConfigID)
}
if parsedBody.Status != scenario.ExpectedStatus {
t.Errorf("expected status to be %s, got %s", scenario.ExpectedStatus, parsedBody.Status)
}
if parsedBody.Title != scenario.ExpectedTitle {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedTitle, parsedBody.Title)
}
if parsedBody.Description != scenario.ExpectedDescription {
t.Errorf("expected description to be %s, got %s", scenario.ExpectedDescription, parsedBody.Description)
}
if scenario.ExpectedSourceURL != "" && parsedBody.SourceURL != scenario.ExpectedSourceURL {
t.Errorf("expected source_url to be %s, got %s", scenario.ExpectedSourceURL, parsedBody.SourceURL)
}
if scenario.ExpectedMetadata != nil {
metadataJSON, _ := json.Marshal(parsedBody.Metadata)
expectedMetadataJSON, _ := json.Marshal(scenario.ExpectedMetadata)
if string(metadataJSON) != string(expectedMetadataJSON) {
t.Errorf("expected metadata to be %s, got %s", string(expectedMetadataJSON), string(metadataJSON))
}
}
// Validate that deduplication_key exists and is not empty
if scenario.ShouldHaveDeduplicationKey {
if parsedBody.DeduplicationKey == "" {
t.Error("expected deduplication_key to be present and non-empty")
}
}
})
}
}
@@ -1,4 +1,4 @@
package jetbrainsspace
package n8n
import (
"bytes"
@@ -15,50 +15,36 @@ import (
)
var (
ErrProjectNotSet = errors.New("project not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Project string `yaml:"project"` // Project name
ChannelID string `yaml:"channel-id"` // Chat Channel ID
Token string `yaml:"token"` // Bearer Token
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
if len(cfg.Project) == 0 {
return ErrProjectNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Project) > 0 {
cfg.Project = override.Project
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
// AlertProvider is the configuration necessary for sending an alert using n8n
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
@@ -90,13 +76,11 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
request, err := http.NewRequest(http.MethodPost, url, buffer)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+cfg.Token)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
@@ -110,78 +94,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
}
type Body struct {
Channel string `json:"channel"`
Content Content `json:"content"`
Title string `json:"title"`
EndpointName string `json:"endpoint_name"`
EndpointGroup string `json:"endpoint_group,omitempty"`
EndpointURL string `json:"endpoint_url"`
AlertDescription string `json:"alert_description,omitempty"`
Resolved bool `json:"resolved"`
Message string `json:"message"`
ConditionResults []ConditionResult `json:"condition_results,omitempty"`
}
type Content struct {
ClassName string `json:"className"`
Style string `json:"style"`
Sections []Section `json:"sections,omitempty"`
}
type Section struct {
ClassName string `json:"className"`
Elements []Element `json:"elements"`
Header string `json:"header"`
}
type Element struct {
ClassName string `json:"className"`
Accessory Accessory `json:"accessory"`
Style string `json:"style"`
Size string `json:"size"`
Content string `json:"content"`
}
type Accessory struct {
ClassName string `json:"className"`
Icon Icon `json:"icon"`
Style string `json:"style"`
}
type Icon struct {
Icon string `json:"icon"`
type ConditionResult struct {
Condition string `json:"condition"`
Success bool `json:"success"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{
Channel: "id:" + cfg.ChannelID,
Content: Content{
ClassName: "ChatMessage.Block",
Sections: []Section{{
ClassName: "MessageSection",
Elements: []Element{},
}},
},
}
var message string
if resolved {
body.Content.Style = "SUCCESS"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
body.Content.Style = "WARNING"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
title := "Gatus"
if cfg.Title != "" {
title = cfg.Title
}
var conditionResults []ConditionResult
for _, conditionResult := range result.ConditionResults {
icon := "warning"
style := "WARNING"
if conditionResult.Success {
icon = "success"
style = "SUCCESS"
}
body.Content.Sections[0].Elements = append(body.Content.Sections[0].Elements, Element{
ClassName: "MessageText",
Accessory: Accessory{
ClassName: "MessageIcon",
Icon: Icon{Icon: icon},
Style: style,
},
Style: style,
Size: "REGULAR",
Content: conditionResult.Condition,
conditionResults = append(conditionResults, ConditionResult{
Condition: conditionResult.Condition,
Success: conditionResult.Success,
})
}
body := Body{
Title: title,
EndpointName: ep.Name,
EndpointGroup: ep.Group,
EndpointURL: ep.URL,
AlertDescription: alert.GetDescription(),
Resolved: resolved,
Message: message,
ConditionResults: conditionResults,
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
@@ -1,4 +1,4 @@
package jetbrainsspace
package n8n
import (
"encoding/json"
@@ -12,11 +12,11 @@ import (
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
@@ -24,10 +24,9 @@ func TestAlertProvider_Validate(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
Config: Config{ChannelID: "http://example.com"},
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
@@ -36,26 +35,21 @@ func TestAlertProvider_ValidateWithOverride(t *testing.T) {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
Config: Config{ChannelID: ""},
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
t.Error("provider webhook URL shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
Project: "foo",
ChannelID: "bar",
Token: "baz",
},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{ChannelID: "foobar"},
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
@@ -79,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -89,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -99,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -109,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -151,45 +145,94 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint endpoint.Endpoint
Alert alert.Alert
Resolved bool
ExpectedBody string
ExpectedBody Body
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: Body{
Title: "Gatus",
EndpointName: "name",
EndpointURL: "https://example.org",
AlertDescription: "description-1",
Resolved: false,
Message: "An alert for name has been triggered due to having failed 3 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: false},
{Condition: "[STATUS] == 200", Success: false},
},
},
},
{
Name: "triggered-with-group",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
Name: "triggered-with-group",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group", URL: "https://example.org"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: Body{
Title: "Gatus",
EndpointName: "name",
EndpointGroup: "group",
EndpointURL: "https://example.org",
AlertDescription: "description-1",
Resolved: false,
Message: "An alert for group/name has been triggered due to having failed 3 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: false},
{Condition: "[STATUS] == 200", Success: false},
},
},
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: Body{
Title: "Gatus",
EndpointName: "name",
EndpointURL: "https://example.org",
AlertDescription: "description-2",
Resolved: true,
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: true},
},
},
},
{
Name: "resolved-with-group",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
Name: "resolved-with-custom-title",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "Custom Title"}},
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: Body{
Title: "Custom Title",
EndpointName: "name",
EndpointURL: "https://example.org",
AlertDescription: "description-2",
Resolved: true,
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: true},
},
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
if err != nil {
t.Fatal("couldn't get config:", err.Error())
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
@@ -200,13 +243,22 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
var actualBody Body
if err := json.Unmarshal(body, &actualBody); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if actualBody.Title != scenario.ExpectedBody.Title {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedBody.Title, actualBody.Title)
}
if actualBody.EndpointName != scenario.ExpectedBody.EndpointName {
t.Errorf("expected endpoint name to be %s, got %s", scenario.ExpectedBody.EndpointName, actualBody.EndpointName)
}
if actualBody.Resolved != scenario.ExpectedBody.Resolved {
t.Errorf("expected resolved to be %v, got %v", scenario.ExpectedBody.Resolved, actualBody.Resolved)
}
if actualBody.Message != scenario.ExpectedBody.Message {
t.Errorf("expected message to be %s, got %s", scenario.ExpectedBody.Message, actualBody.Message)
}
})
}
}
@@ -231,67 +283,67 @@ func TestAlertProvider_GetConfig(t *testing.T) {
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
@@ -300,14 +352,8 @@ func TestAlertProvider_GetConfig(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
if got.Project != scenario.ExpectedOutput.Project {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+3 -3
View File
@@ -16,11 +16,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
@@ -105,11 +105,11 @@ var (
_ AlertProvider = (*ifttt.AlertProvider)(nil)
_ AlertProvider = (*ilert.AlertProvider)(nil)
_ AlertProvider = (*incidentio.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*line.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*n8n.AlertProvider)(nil)
_ AlertProvider = (*newrelic.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
@@ -146,11 +146,11 @@ var (
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
_ Config[ilert.Config] = (*ilert.Config)(nil)
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
_ Config[line.Config] = (*line.Config)(nil)
_ Config[matrix.Config] = (*matrix.Config)(nil)
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
_ Config[n8n.Config] = (*n8n.Config)(nil)
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
+11 -4
View File
@@ -20,7 +20,8 @@ var (
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
@@ -34,6 +35,9 @@ func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Slack
@@ -73,7 +77,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
@@ -111,7 +115,7 @@ type Field struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
@@ -138,13 +142,16 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
Text: "",
Attachments: []Attachment{
{
Title: ":helmet_with_white_cross: Gatus",
Title: cfg.Title,
Text: message + description,
Short: false,
Color: color,
},
},
}
if len(body.Attachments[0].Title) == 0 {
body.Attachments[0].Title = ":helmet_with_white_cross: Gatus"
}
if len(formattedConditionResults) > 0 {
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
Title: "Condition results",
+18 -5
View File
@@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -158,7 +158,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "triggered-with-group",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -175,7 +175,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
@@ -183,12 +183,20 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "resolved-with-group",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved-with-group-and-custom-title",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "custom title"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\"custom title\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -199,7 +207,12 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
if err != nil {
t.Fatal("couldn't get config:", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
@@ -166,7 +166,10 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
Value: conditionResult.Condition,
})
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "**Description**: " + alertDescription
}
cardContent := AdaptiveCardBody{
Type: "AdaptiveCard",
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
@@ -190,6 +193,11 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
Text: message,
Wrap: true,
},
{
Type: "TextBlock",
Text: description,
Wrap: true,
},
{
Type: "FactSet",
Facts: facts,
@@ -152,14 +152,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-1\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved-with-no-conditions",
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
}
for _, scenario := range scenarios {
+1 -1
View File
@@ -147,7 +147,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
}
var text string
if len(alert.GetDescription()) > 0 {
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n%s \n%s", message, alert.GetDescription(), formattedConditionResults)
} else {
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
}
+12 -4
View File
@@ -124,6 +124,7 @@ func TestAlertProvider_Send(t *testing.T) {
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
descriptionWithLink := "[link](https://example.org/)"
scenarios := []struct {
Name string
Provider AlertProvider
@@ -137,14 +138,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\ndescription-2 \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "resolved-with-no-conditions",
@@ -152,14 +153,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\ndescription-2 \\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "send to topic",
Provider: AlertProvider{DefaultConfig: Config{ID: "123", TopicID: "7"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
},
{
Name: "triggered with link in description",
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &descriptionWithLink, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n[link](https://example.org/) \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
}
for _, scenario := range scenarios {
+1
View File
@@ -84,6 +84,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/history", ResponseTimeHistory)
// This endpoint requires authz with bearer token, so technically it is protected
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
// SPA
+60
View File
@@ -126,3 +126,63 @@ func ResponseTimeChart(c *fiber.Ctx) error {
}
return nil
}
func ResponseTimeHistory(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
}
endpointKey, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(endpointKey, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
}
if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
if len(hourlyAverageResponseTime) == 0 {
return c.Status(200).JSON(map[string]interface{}{
"timestamps": []int64{},
"values": []int{},
})
}
hourlyTimestamps := make([]int, 0, len(hourlyAverageResponseTime))
earliestTimestamp := int64(0)
for hourlyTimestamp := range hourlyAverageResponseTime {
hourlyTimestamps = append(hourlyTimestamps, int(hourlyTimestamp))
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
earliestTimestamp = hourlyTimestamp
}
}
for earliestTimestamp > from.Unix() {
earliestTimestamp -= int64(time.Hour.Seconds())
hourlyTimestamps = append(hourlyTimestamps, int(earliestTimestamp))
}
sort.Ints(hourlyTimestamps)
timestamps := make([]int64, 0, len(hourlyTimestamps))
values := make([]int, 0, len(hourlyTimestamps))
for _, hourlyTimestamp := range hourlyTimestamps {
timestamp := int64(hourlyTimestamp)
averageResponseTime := hourlyAverageResponseTime[timestamp]
timestamps = append(timestamps, timestamp*1000)
values = append(values, averageResponseTime)
}
return c.Status(http.StatusOK).JSON(map[string]interface{}{
"timestamps": timestamps,
"values": values,
})
}
+66
View File
@@ -81,3 +81,69 @@ func TestResponseTimeChart(t *testing.T) {
})
}
}
func TestResponseTimeHistory(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
}
scenarios := []Scenario{
{
Name: "history-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/history",
ExpectedCode: http.StatusOK,
},
{
Name: "history-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/history",
ExpectedCode: http.StatusOK,
},
{
Name: "history-response-time-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d/history",
ExpectedCode: http.StatusOK,
},
{
Name: "history-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/history",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "history-response-time-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d/history",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
response, err := router.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
}
})
}
}
+10 -1
View File
@@ -68,11 +68,20 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
return c.Status(500).SendString(err.Error())
}
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
inEndpointMaintenanceWindow := false
for _, maintenanceWindow := range externalEndpoint.MaintenanceWindows {
if maintenanceWindow.IsUnderMaintenance() {
logr.Debug("[api.CreateExternalEndpointResult] Under endpoint maintenance window")
inEndpointMaintenanceWindow = true
}
}
// Check if an alert should be triggered or resolved
if !cfg.Maintenance.IsUnderMaintenance() {
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
} else {
logr.Debug("[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window")
}
if cfg.Metrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
+9 -2
View File
@@ -3,6 +3,7 @@ package api
import (
"errors"
"fmt"
"net/url"
"time"
"github.com/TwiN/gatus/v5/storage/store"
@@ -25,7 +26,10 @@ func UptimeRaw(c *fiber.Ctx) error {
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
@@ -57,7 +61,10 @@ func ResponseTimeRaw(c *fiber.Ctx) error {
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
+35 -15
View File
@@ -1,17 +1,19 @@
package client
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/smtp"
"os"
"runtime"
"strings"
"time"
@@ -301,7 +303,7 @@ func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
}
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, []byte, error) {
type Body struct {
Command string `json:"command"`
}
@@ -309,26 +311,30 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
var b Body
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err
return false, 0, nil, err
}
sess, err := sshClient.NewSession()
if err != nil {
return false, 0, err
return false, 0, nil, err
}
// Capture stdout
var stdout bytes.Buffer
sess.Stdout = &stdout
err = sess.Start(b.Command)
if err != nil {
return false, 0, err
return false, 0, nil, err
}
defer sess.Close()
err = sess.Wait()
output := stdout.Bytes()
if err == nil {
return true, 0, nil
return true, 0, output, nil
}
var exitErr *ssh.ExitError
if ok := errors.As(err, &exitErr); !ok {
return false, 0, err
return false, 0, nil, err
}
return true, exitErr.ExitStatus(), nil
return true, exitErr.ExitStatus(), output, nil
}
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
@@ -338,12 +344,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
pinger := ping.New(address)
pinger.Count = 1
pinger.Timeout = config.Timeout
// Set the pinger's privileged mode to true for every GOOS except darwin
// See https://github.com/TwiN/gatus/issues/132
//
// Note that for this to work on Linux, Gatus must run with sudo privileges.
// See https://github.com/prometheus-community/pro-bing#linux
pinger.SetPrivileged(runtime.GOOS != "darwin")
pinger.SetPrivileged(ShouldRunPingerAsPrivileged())
pinger.SetNetwork(config.Network)
err := pinger.Run()
if err != nil {
@@ -359,6 +360,25 @@ func Ping(address string, config *Config) (bool, time.Duration) {
return true, 0
}
// ShouldRunPingerAsPrivileged will determine whether or not to run pinger in privileged mode.
// It should be set to privileged when running as root, and always on windows. See https://pkg.go.dev/github.com/macrat/go-parallel-pinger#Pinger.SetPrivileged
func ShouldRunPingerAsPrivileged() bool {
// Set the pinger's privileged mode to false for darwin
// See https://github.com/TwiN/gatus/issues/132
// linux should also be set to false, but there are potential complications
// See https://github.com/TwiN/gatus/pull/748 and https://github.com/TwiN/gatus/issues/697#issuecomment-2081700989
//
// Note that for this to work on Linux, Gatus must run with sudo privileges. (in certain cases)
// See https://github.com/prometheus-community/pro-bing#linux
if runtime.GOOS == "windows" {
return true
}
// To actually check for cap_net_raw capabilities, we would need to add "kernel.org/pub/linux/libs/security/libcap/cap" to gatus.
// Or use a syscall and check for permission errors, but this requires platform specific compilation
// As a backstop we can simply check the effective user id and run as privileged when running as root
return os.Geteuid() == 0
}
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
const (
@@ -516,4 +536,4 @@ func reverseNameForIP(ipStr string) (string, error) {
nibbles[i], nibbles[j] = nibbles[j], nibbles[i]
}
return strings.Join(nibbles, ".") + ".ip6.arpa.", nil
}
}
+38 -16
View File
@@ -6,6 +6,8 @@ import (
"io"
"net/http"
"net/netip"
"os"
"runtime"
"testing"
"time"
@@ -129,10 +131,37 @@ func TestPing(t *testing.T) {
}
}
func TestShouldRunPingerAsPrivileged(t *testing.T) {
// Don't run in parallel since we're testing system-dependent behavior
if runtime.GOOS == "windows" {
result := ShouldRunPingerAsPrivileged()
if !result {
t.Error("On Windows, ShouldRunPingerAsPrivileged() should return true")
}
return
}
// Non-Windows tests
result := ShouldRunPingerAsPrivileged()
isRoot := os.Geteuid() == 0
// Test cases based on current environment
if isRoot {
if !result {
t.Error("When running as root, ShouldRunPingerAsPrivileged() should return true")
}
} else {
// When not root, the result depends on raw socket creation
// We can at least verify the function runs without panic
t.Logf("Non-root privileged result: %v", result)
}
}
func TestCanPerformStartTLS(t *testing.T) {
type args struct {
address string
insecure bool
address string
insecure bool
dnsresolver string
}
tests := []struct {
@@ -168,7 +197,7 @@ func TestCanPerformStartTLS(t *testing.T) {
{
name: "dns resolver",
args: args{
address: "smtp.gmail.com:587",
address: "smtp.gmail.com:587",
dnsresolver: "tcp://1.1.1.1:53",
},
wantConnected: true,
@@ -340,7 +369,7 @@ func TestQueryWebSocket(t *testing.T) {
}
func TestTlsRenegotiation(t *testing.T) {
tests := []struct {
scenarios := []struct {
name string
cfg TLSConfig
expectedConfig tls.RenegotiationSupport
@@ -371,12 +400,12 @@ func TestTlsRenegotiation(t *testing.T) {
expectedConfig: tls.RenegotiateNever,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
tls := &tls.Config{}
tlsConfig := configureTLS(tls, test.cfg)
if tlsConfig.Renegotiation != test.expectedConfig {
t.Errorf("expected tls renegotiation to be %v, but got %v", test.expectedConfig, tls.Renegotiation)
tlsConfig := configureTLS(tls, scenario.cfg)
if tlsConfig.Renegotiation != scenario.expectedConfig {
t.Errorf("expected tls renegotiation to be %v, but got %v", scenario.expectedConfig, tls.Renegotiation)
}
})
}
@@ -513,14 +542,11 @@ func TestQueryDNS(t *testing.T) {
func TestCheckSSHBanner(t *testing.T) {
cfg := &Config{Timeout: 3}
t.Run("no-auth-ssh", func(t *testing.T) {
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
if err != nil {
t.Errorf("Expected: error != nil, got: %v ", err)
}
if connected == false {
t.Errorf("Expected: connected == true, got: %v", connected)
}
@@ -528,14 +554,11 @@ func TestCheckSSHBanner(t *testing.T) {
t.Errorf("Expected: 0, got: %v", status)
}
})
t.Run("invalid-address", func(t *testing.T) {
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
if err == nil {
t.Errorf("Expected: error, got: %v ", err)
}
if connected != false {
t.Errorf("Expected: connected == false, got: %v", connected)
}
@@ -543,5 +566,4 @@ func TestCheckSSHBanner(t *testing.T) {
t.Errorf("Expected: 1, got: %v", status)
}
})
}
+17 -2
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"time"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
"github.com/TwiN/logr"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
@@ -69,13 +70,19 @@ type Config struct {
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`
httpClient *http.Client
// Network (ip, ip4 or ip6) for the ICMP client
Network string `yaml:"network"`
// TLS configuration (optional)
TLS *TLSConfig `yaml:"tls,omitempty"`
// Tunnel is the name of the SSH tunnel to use for the client
Tunnel string `yaml:"tunnel,omitempty"`
// ResolvedTunnel is the resolved SSH tunnel for this specific Config
ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"`
httpClient *http.Client
}
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
@@ -265,6 +272,14 @@ func (c *Config) getHTTPClient() *http.Client {
} else if c.HasIAPConfig() {
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
}
if c.ResolvedTunnel != nil {
// Use SSH tunnel dialer
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return c.ResolvedTunnel.Dial(network, addr)
}
}
}
}
return c.httpClient
}
+71
View File
@@ -0,0 +1,71 @@
package client
import (
"context"
"crypto/tls"
"net"
"time"
"github.com/TwiN/logr"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
health "google.golang.org/grpc/health/grpc_health_v1"
)
// PerformGRPCHealthCheck dials a gRPC target and performs the standard Health/Check RPC.
// Returns whether a connection was established, the serving status string, an error (if any), and the elapsed duration.
func PerformGRPCHealthCheck(address string, useTLS bool, cfg *Config) (bool, string, error, time.Duration) {
if cfg == nil {
cfg = GetDefaultConfig()
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
var opts []grpc.DialOption
// Transport credentials
if useTLS {
tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure}
if cfg.HasTLSConfig() && cfg.TLS.isValid() == nil {
tlsCfg = configureTLS(tlsCfg, *cfg.TLS)
}
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
// Custom dialer for DNS resolver or SSH tunnel
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
if cfg.ResolvedTunnel != nil {
return cfg.ResolvedTunnel.Dial("tcp", addr)
}
if cfg.HasCustomDNSResolver() {
resolverCfg, err := cfg.parseDNSResolver()
if err != nil {
// Shouldn't happen because already validated; log and fall back
logr.Errorf("[client.PerformGRPCHealthCheck] invalid DNS resolver: %v", err)
} else {
d := &net.Dialer{Resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, resolverCfg.Protocol, resolverCfg.Host+":"+resolverCfg.Port)
}}}
return d.DialContext(ctx, "tcp", addr)
}
}
var d net.Dialer
return d.DialContext(ctx, "tcp", addr)
}))
start := time.Now()
conn, err := grpc.DialContext(ctx, address, opts...)
if err != nil {
return false, "", err, time.Since(start)
}
defer conn.Close()
client := health.NewHealthClient(conn)
resp, err := client.Check(ctx, &health.HealthCheckRequest{Service: ""})
if err != nil {
return false, "", err, time.Since(start)
}
return true, resp.GetStatus().String(), nil, time.Since(start)
}
+4
View File
@@ -53,6 +53,10 @@ type Announcement struct {
// Message is the user-facing text describing the announcement
Message string `yaml:"message" json:"message"`
// Archived indicates whether the announcement should be displayed in the historical section
// instead of at the top of the status page
Archived bool `yaml:"archived,omitempty" json:"archived,omitempty"`
}
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
+241
View File
@@ -0,0 +1,241 @@
package announcement
import (
"errors"
"testing"
"time"
)
func TestAnnouncement_ValidateAndSetDefaults(t *testing.T) {
now := time.Now()
scenarios := []struct {
name string
announcement *Announcement
expectedError error
expectedType string
}{
{
name: "valid-announcement-with-all-fields",
announcement: &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "This is a test announcement",
Archived: false,
},
expectedError: nil,
expectedType: TypeWarning,
},
{
name: "valid-announcement-with-archived-true",
announcement: &Announcement{
Timestamp: now,
Type: TypeOperational,
Message: "This is an archived announcement",
Archived: true,
},
expectedError: nil,
expectedType: TypeOperational,
},
{
name: "valid-announcement-with-empty-type-should-default-to-none",
announcement: &Announcement{
Timestamp: now,
Message: "This announcement has no type",
},
expectedError: nil,
expectedType: TypeNone,
},
{
name: "invalid-announcement-with-empty-message",
announcement: &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "",
},
expectedError: ErrEmptyMessage,
},
{
name: "invalid-announcement-with-zero-timestamp",
announcement: &Announcement{
Timestamp: time.Time{},
Type: TypeWarning,
Message: "Test message",
},
expectedError: ErrMissingTimestamp,
},
{
name: "invalid-announcement-with-invalid-type",
announcement: &Announcement{
Timestamp: now,
Type: "invalid-type",
Message: "Test message",
},
expectedError: ErrInvalidAnnouncementType,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.announcement.ValidateAndSetDefaults()
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
if scenario.expectedError == nil && scenario.announcement.Type != scenario.expectedType {
t.Errorf("expected type %s, got %s", scenario.expectedType, scenario.announcement.Type)
}
})
}
}
func TestAnnouncement_ValidateAndSetDefaults_AllTypes(t *testing.T) {
now := time.Now()
validTypes := []string{TypeOutage, TypeWarning, TypeInformation, TypeOperational, TypeNone}
for _, validType := range validTypes {
t.Run("type-"+validType, func(t *testing.T) {
announcement := &Announcement{
Timestamp: now,
Type: validType,
Message: "Test message",
}
if err := announcement.ValidateAndSetDefaults(); err != nil {
t.Errorf("expected no error for type %s, got %v", validType, err)
}
if announcement.Type != validType {
t.Errorf("expected type %s, got %s", validType, announcement.Type)
}
})
}
}
func TestSortByTimestamp(t *testing.T) {
now := time.Now()
earlier := now.Add(-1 * time.Hour)
later := now.Add(1 * time.Hour)
announcements := []*Announcement{
{Timestamp: now, Message: "now"},
{Timestamp: later, Message: "later"},
{Timestamp: earlier, Message: "earlier"},
}
SortByTimestamp(announcements)
if announcements[0].Timestamp != later {
t.Error("expected first announcement to be the latest")
}
if announcements[1].Timestamp != now {
t.Error("expected second announcement to be the middle one")
}
if announcements[2].Timestamp != earlier {
t.Error("expected third announcement to be the earliest")
}
}
func TestSortByTimestamp_WithArchivedField(t *testing.T) {
now := time.Now()
earlier := now.Add(-1 * time.Hour)
later := now.Add(1 * time.Hour)
announcements := []*Announcement{
{Timestamp: now, Message: "now", Archived: false},
{Timestamp: later, Message: "later", Archived: true},
{Timestamp: earlier, Message: "earlier", Archived: false},
}
SortByTimestamp(announcements)
// Sorting should be by timestamp only, not affected by archived status
if announcements[0].Timestamp != later {
t.Error("expected first announcement to be the latest, regardless of archived status")
}
if !announcements[0].Archived {
t.Error("expected first announcement to be archived")
}
if announcements[1].Timestamp != now {
t.Error("expected second announcement to be the middle one")
}
if announcements[2].Timestamp != earlier {
t.Error("expected third announcement to be the earliest")
}
}
func TestValidateAndSetDefaults_Slice(t *testing.T) {
now := time.Now()
scenarios := []struct {
name string
announcements []*Announcement
expectedError error
shouldValidate bool
}{
{
name: "all-valid-announcements",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "First announcement"},
{Timestamp: now, Type: TypeOperational, Message: "Second announcement"},
},
expectedError: nil,
shouldValidate: true,
},
{
name: "mixed-archived-announcements",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Active announcement", Archived: false},
{Timestamp: now, Type: TypeOperational, Message: "Archived announcement", Archived: true},
},
expectedError: nil,
shouldValidate: true,
},
{
name: "one-invalid-announcement-in-slice",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: now, Type: TypeOperational, Message: ""},
},
expectedError: ErrEmptyMessage,
shouldValidate: false,
},
{
name: "announcement-with-missing-timestamp",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: time.Time{}, Type: TypeOperational, Message: "Invalid announcement"},
},
expectedError: ErrMissingTimestamp,
shouldValidate: false,
},
{
name: "announcement-with-invalid-type",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: now, Type: "bad-type", Message: "Invalid announcement"},
},
expectedError: ErrInvalidAnnouncementType,
shouldValidate: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := ValidateAndSetDefaults(scenario.announcements)
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
})
}
}
func TestAnnouncement_ArchivedFieldDefaults(t *testing.T) {
now := time.Now()
announcement := &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "Test announcement",
// Archived not set, should default to false
}
if err := announcement.ValidateAndSetDefaults(); err != nil {
t.Errorf("expected no error, got %v", err)
}
// Zero value for bool is false
if announcement.Archived {
t.Error("expected Archived to default to false")
}
}
func TestValidateAndSetDefaults_EmptySlice(t *testing.T) {
announcements := []*Announcement{}
if err := ValidateAndSetDefaults(announcements); err != nil {
t.Errorf("expected no error for empty slice, got %v", err)
}
}
+107 -49
View File
@@ -14,6 +14,7 @@ import (
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/announcement"
"github.com/TwiN/gatus/v5/config/connectivity"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -21,6 +22,7 @@ import (
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/config/tunneling"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/security"
@@ -114,6 +116,9 @@ type Config struct {
// Connectivity is the configuration for connectivity
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
// Tunneling is the configuration for SSH tunneling
Tunneling *tunneling.Config `yaml:"tunneling,omitempty"`
// Announcements is the list of system-wide announcements
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
@@ -295,55 +300,111 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
}
// XXX: End of v6.0.0 removals
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
if err := validateSecurityConfig(config); err != nil {
ValidateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
if err := ValidateSecurityConfig(config); err != nil {
return nil, err
}
if err := validateEndpointsConfig(config); err != nil {
if err := ValidateEndpointsConfig(config); err != nil {
return nil, err
}
if err := validateWebConfig(config); err != nil {
if err := ValidateWebConfig(config); err != nil {
return nil, err
}
if err := validateUIConfig(config); err != nil {
if err := ValidateUIConfig(config); err != nil {
return nil, err
}
if err := validateMaintenanceConfig(config); err != nil {
if err := ValidateMaintenanceConfig(config); err != nil {
return nil, err
}
if err := validateStorageConfig(config); err != nil {
if err := ValidateStorageConfig(config); err != nil {
return nil, err
}
if err := validateRemoteConfig(config); err != nil {
if err := ValidateRemoteConfig(config); err != nil {
return nil, err
}
if err := validateConnectivityConfig(config); err != nil {
if err := ValidateConnectivityConfig(config); err != nil {
return nil, err
}
if err := validateAnnouncementsConfig(config); err != nil {
if err := ValidateTunnelingConfig(config); err != nil {
return nil, err
}
if err := validateSuitesConfig(config); err != nil {
if err := ValidateAnnouncementsConfig(config); err != nil {
return nil, err
}
if err := validateUniqueKeys(config); err != nil {
if err := ValidateSuitesConfig(config); err != nil {
return nil, err
}
validateAndSetConcurrencyDefaults(config)
if err := ValidateUniqueKeys(config); err != nil {
return nil, err
}
ValidateAndSetConcurrencyDefaults(config)
// Cross-config changes
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
}
return
}
func validateConnectivityConfig(config *Config) error {
func ValidateConnectivityConfig(config *Config) error {
if config.Connectivity != nil {
return config.Connectivity.ValidateAndSetDefaults()
}
return nil
}
func validateAnnouncementsConfig(config *Config) error {
// ValidateTunnelingConfig validates the tunneling configuration and resolves tunnel references
// NOTE: This must be called after ValidateEndpointsConfig and ValidateSuitesConfig
// because it resolves tunnel references in endpoint and suite client configurations
func ValidateTunnelingConfig(config *Config) error {
if config.Tunneling != nil {
if err := config.Tunneling.ValidateAndSetDefaults(); err != nil {
return err
}
// Resolve tunnel references in all endpoints
for _, ep := range config.Endpoints {
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
return fmt.Errorf("endpoint '%s': %w", ep.Key(), err)
}
}
// Resolve tunnel references in suite endpoints
for _, s := range config.Suites {
for _, ep := range s.Endpoints {
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
return fmt.Errorf("suite '%s' endpoint '%s': %w", s.Key(), ep.Key(), err)
}
}
}
// TODO: Add tunnel support for alert providers when needed
}
return nil
}
// resolveTunnelForClientConfig resolves tunnel references in a client configuration
func resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) error {
if clientConfig == nil || clientConfig.Tunnel == "" {
return nil
}
// Validate tunnel name
tunnelName := strings.TrimSpace(clientConfig.Tunnel)
if tunnelName == "" {
return fmt.Errorf("tunnel name cannot be empty")
}
if config.Tunneling == nil {
return fmt.Errorf("tunnel '%s' referenced but no tunneling configuration defined", tunnelName)
}
_, exists := config.Tunneling.Tunnels[tunnelName]
if !exists {
return fmt.Errorf("tunnel '%s' not found in tunneling configuration", tunnelName)
}
// Get or create the SSH tunnel instance and store it directly in client config
tunnel, err := config.Tunneling.GetTunnel(tunnelName)
if err != nil {
return fmt.Errorf("failed to get tunnel '%s': %w", tunnelName, err)
}
clientConfig.ResolvedTunnel = tunnel
return nil
}
func ValidateAnnouncementsConfig(config *Config) error {
if config.Announcements != nil {
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
return err
@@ -354,7 +415,7 @@ func validateAnnouncementsConfig(config *Config) error {
return nil
}
func validateRemoteConfig(config *Config) error {
func ValidateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
return err
@@ -363,7 +424,7 @@ func validateRemoteConfig(config *Config) error {
return nil
}
func validateStorageConfig(config *Config) error {
func ValidateStorageConfig(config *Config) error {
if config.Storage == nil {
config.Storage = &storage.Config{
Type: storage.TypeMemory,
@@ -378,7 +439,7 @@ func validateStorageConfig(config *Config) error {
return nil
}
func validateMaintenanceConfig(config *Config) error {
func ValidateMaintenanceConfig(config *Config) error {
if config.Maintenance == nil {
config.Maintenance = maintenance.GetDefaultConfig()
} else {
@@ -389,7 +450,7 @@ func validateMaintenanceConfig(config *Config) error {
return nil
}
func validateUIConfig(config *Config) error {
func ValidateUIConfig(config *Config) error {
if config.UI == nil {
config.UI = ui.GetDefaultConfig()
} else {
@@ -400,7 +461,7 @@ func validateUIConfig(config *Config) error {
return nil
}
func validateWebConfig(config *Config) error {
func ValidateWebConfig(config *Config) error {
if config.Web == nil {
config.Web = web.GetDefaultConfig()
} else {
@@ -409,11 +470,11 @@ func validateWebConfig(config *Config) error {
return nil
}
func validateEndpointsConfig(config *Config) error {
func ValidateEndpointsConfig(config *Config) error {
duplicateValidationMap := make(map[string]bool)
// Validate endpoints
for _, ep := range config.Endpoints {
logr.Debugf("[config.validateEndpointsConfig] Validating endpoint with key %s", ep.Key())
logr.Debugf("[config.ValidateEndpointsConfig] Validating endpoint with key %s", ep.Key())
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
} else {
@@ -423,10 +484,10 @@ func validateEndpointsConfig(config *Config) error {
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
}
}
logr.Infof("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
logr.Infof("[config.ValidateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
// Validate external endpoints
for _, ee := range config.ExternalEndpoints {
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
logr.Debugf("[config.ValidateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
} else {
@@ -436,13 +497,13 @@ func validateEndpointsConfig(config *Config) error {
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
}
}
logr.Infof("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
logr.Infof("[config.ValidateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
return nil
}
func validateSuitesConfig(config *Config) error {
func ValidateSuitesConfig(config *Config) error {
if config.Suites == nil || len(config.Suites) == 0 {
logr.Info("[config.validateSuitesConfig] No suites configured")
logr.Info("[config.ValidateSuitesConfig] No suites configured")
return nil
}
suiteNames := make(map[string]bool)
@@ -471,11 +532,11 @@ func validateSuitesConfig(config *Config) error {
}
}
}
logr.Infof("[config.validateSuitesConfig] Validated %d suite(s)", len(config.Suites))
logr.Infof("[config.ValidateSuitesConfig] Validated %d suite(s)", len(config.Suites))
return nil
}
func validateUniqueKeys(config *Config) error {
func ValidateUniqueKeys(config *Config) error {
keyMap := make(map[string]string) // key -> description for error messages
// Check all endpoints
for _, ep := range config.Endpoints {
@@ -512,26 +573,23 @@ func validateUniqueKeys(config *Config) error {
return nil
}
func validateSecurityConfig(config *Config) error {
func ValidateSecurityConfig(config *Config) error {
if config.Security != nil {
if config.Security.IsValid() {
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
} else {
// If there was an attempt to configure security, then it must mean that some confidential or private
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
if !config.Security.ValidateAndSetDefaults() {
logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated")
return ErrInvalidSecurityConfig
}
}
return nil
}
// validateAlertingConfig validates the alerting configuration
// ValidateAlertingConfig validates the alerting configuration
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
// sets the default alert values when none are set.
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
if alertingConfig == nil {
logr.Info("[config.validateAlertingConfig] Alerting is not configured")
logr.Info("[config.ValidateAlertingConfig] Alerting is not configured")
return
}
alertTypes := []alert.Type{
@@ -549,11 +607,11 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alert.TypeIFTTT,
alert.TypeIlert,
alert.TypeIncidentIO,
alert.TypeJetBrainsSpace,
alert.TypeLine,
alert.TypeMatrix,
alert.TypeMattermost,
alert.TypeMessagebird,
alert.TypeN8N,
alert.TypeNewRelic,
alert.TypeNtfy,
alert.TypeOpsgenie,
@@ -586,12 +644,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
for _, ep := range endpoints {
for alertIndex, endpointAlert := range ep.Alerts {
if alertType == endpointAlert.Type {
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
}
}
}
@@ -600,12 +658,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
for _, ee := range externalEndpoints {
for alertIndex, endpointAlert := range ee.Alerts {
if alertType == endpointAlert.Type {
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
}
}
}
@@ -614,7 +672,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
}
validProviders = append(validProviders, alertType)
} else {
logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
logr.Warnf("[config.ValidateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider)
}
@@ -622,19 +680,19 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
invalidProviders = append(invalidProviders, alertType)
}
}
logr.Infof("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
logr.Infof("[config.ValidateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
}
func validateAndSetConcurrencyDefaults(config *Config) {
func ValidateAndSetConcurrencyDefaults(config *Config) {
if config.DisableMonitoringLock {
config.Concurrency = 0
logr.Warn("WARNING: The 'disable-monitoring-lock' configuration has been deprecated and will be removed in v6.0.0")
logr.Warn("WARNING: Please set 'concurrency: 0' instead")
logr.Debug("[config.validateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
logr.Debug("[config.ValidateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
} else if config.Concurrency <= 0 && !config.DisableMonitoringLock {
config.Concurrency = DefaultConcurrency
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
} else {
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
}
}
+206 -65
View File
@@ -25,7 +25,6 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -53,6 +52,9 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/config/tunneling"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/storage"
"gopkg.in/yaml.v3"
@@ -772,10 +774,6 @@ alerting:
to: "+1-234-567-8901"
teams:
webhook-url: "http://example.com"
jetbrainsspace:
project: "foo"
channel-id: "bar"
token: "baz"
endpoints:
- name: website
@@ -798,7 +796,6 @@ endpoints:
success-threshold: 15
- type: teams
- type: pushover
- type: jetbrainsspace
conditions:
- "[STATUS] == 200"
`))
@@ -825,8 +822,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 10 {
t.Fatal("There should've been 10 alerts configured")
if len(config.Endpoints[0].Alerts) != 9 {
t.Fatal("There should've been 9 alerts configured")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -933,12 +930,6 @@ endpoints:
if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
}
if !config.Endpoints[0].Alerts[9].IsEnabled() {
t.Error("The alert should've been enabled")
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
@@ -998,14 +989,6 @@ alerting:
webhook-url: "http://example.com"
default-alert:
enabled: true
jetbrainsspace:
project: "foo"
channel-id: "bar"
token: "baz"
default-alert:
enabled: true
failure-threshold: 5
success-threshold: 3
email:
from: "from@example.com"
username: "from@example.com"
@@ -1044,7 +1027,6 @@ endpoints:
- type: twilio
- type: teams
- type: pushover
- type: jetbrainsspace
- type: email
- type: gotify
conditions:
@@ -1166,22 +1148,6 @@ endpoints:
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.JetBrainsSpace == nil || config.Alerting.JetBrainsSpace.Validate() != nil {
t.Fatal("JetBrainsSpace alerting config should've been valid")
}
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project)
}
if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID)
}
if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.DefaultConfig.Token)
}
if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
t.Fatal("Email alerting config should've been valid")
}
@@ -1253,8 +1219,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 12 {
t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts))
if len(config.Endpoints[0].Alerts) != 11 {
t.Fatalf("There should've been 11 alerts configured, got %d", len(config.Endpoints[0].Alerts))
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -1371,21 +1337,21 @@ endpoints:
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
}
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
if config.Endpoints[0].Alerts[9].Type != alert.TypeEmail {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[9].Type)
}
if !config.Endpoints[0].Alerts[9].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[9].FailureThreshold != 5 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[9].FailureThreshold)
if config.Endpoints[0].Alerts[9].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].FailureThreshold)
}
if config.Endpoints[0].Alerts[9].SuccessThreshold != 3 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].SuccessThreshold)
if config.Endpoints[0].Alerts[9].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[9].SuccessThreshold)
}
if config.Endpoints[0].Alerts[10].Type != alert.TypeEmail {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[10].Type)
if config.Endpoints[0].Alerts[10].Type != alert.TypeGotify {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[10].Type)
}
if !config.Endpoints[0].Alerts[10].IsEnabled() {
t.Error("The alert should've been enabled")
@@ -1396,19 +1362,6 @@ endpoints:
if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold)
}
if config.Endpoints[0].Alerts[11].Type != alert.TypeGotify {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[11].Type)
}
if !config.Endpoints[0].Alerts[11].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[11].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[11].FailureThreshold)
}
if config.Endpoints[0].Alerts[11].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[11].SuccessThreshold)
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
@@ -1850,7 +1803,7 @@ endpoints:
if config.Security == nil {
t.Fatal("config.Security shouldn't have been nil")
}
if !config.Security.IsValid() {
if !config.Security.ValidateAndSetDefaults() {
t.Error("Security config should've been valid")
}
if config.Security.Basic == nil {
@@ -1914,7 +1867,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
IFTTT: &ifttt.AlertProvider{},
Ilert: &ilert.AlertProvider{},
IncidentIO: &incidentio.AlertProvider{},
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
Line: &line.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
@@ -1959,7 +1911,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},
{alertType: alert.TypeIlert, expected: alertingConfig.Ilert},
{alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
{alertType: alert.TypeLine, expected: alertingConfig.Line},
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
@@ -2484,3 +2435,193 @@ suites:
})
}
}
func TestValidateTunnelingConfig(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string
}{
{
name: "valid tunneling config",
config: &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
Endpoints: []*endpoint.Endpoint{
{
Name: "test-endpoint",
URL: "http://example.com/health",
ClientConfig: &client.Config{
Tunnel: "test",
},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
wantErr: false,
},
{
name: "invalid tunnel reference in endpoint",
config: &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
Endpoints: []*endpoint.Endpoint{
{
Name: "test-endpoint",
URL: "http://example.com/health",
ClientConfig: &client.Config{
Tunnel: "nonexistent",
},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
wantErr: true,
errMsg: "endpoint '_test-endpoint': tunnel 'nonexistent' not found in tunneling configuration",
},
{
name: "invalid tunnel reference in suite endpoint",
config: &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
Suites: []*suite.Suite{
{
Name: "test-suite",
Endpoints: []*endpoint.Endpoint{
{
Name: "suite-endpoint",
URL: "http://example.com/health",
ClientConfig: &client.Config{
Tunnel: "invalid",
},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
},
},
wantErr: true,
errMsg: "suite '_test-suite' endpoint '_suite-endpoint': tunnel 'invalid' not found in tunneling configuration",
},
{
name: "no tunneling config",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "test-endpoint",
URL: "http://example.com/health",
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTunnelingConfig(tt.config)
if tt.wantErr {
if err == nil {
t.Error("ValidateTunnelingConfig() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("ValidateTunnelingConfig() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ValidateTunnelingConfig() unexpected error = %v", err)
}
})
}
}
func TestResolveTunnelForClientConfig(t *testing.T) {
config := &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
}
err := config.Tunneling.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("Failed to validate tunnel config: %v", err)
}
tests := []struct {
name string
clientConfig *client.Config
wantErr bool
errMsg string
}{
{
name: "valid tunnel reference",
clientConfig: &client.Config{
Tunnel: "test",
},
wantErr: false,
},
{
name: "invalid tunnel reference",
clientConfig: &client.Config{
Tunnel: "nonexistent",
},
wantErr: true,
errMsg: "tunnel 'nonexistent' not found in tunneling configuration",
},
{
name: "no tunnel reference",
clientConfig: &client.Config{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := resolveTunnelForClientConfig(config, tt.clientConfig)
if tt.wantErr {
if err == nil {
t.Error("resolveTunnelForClientConfig() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("resolveTunnelForClientConfig() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("resolveTunnelForClientConfig() unexpected error = %v", err)
}
})
}
}
+63 -19
View File
@@ -209,35 +209,79 @@ func sanitizeAndResolveNumericalWithContext(list []string, result *Result, conte
}
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
resolvedStrings := make([]string, 2)
for i := 0; i < 2; i++ {
// Check if the parameter is a certificate or domain expiration placeholder
if parameters[i] == CertificateExpirationPlaceholder || parameters[i] == DomainExpirationPlaceholder {
// Format as duration string (convert milliseconds back to duration)
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
resolvedStrings[i] = formatDuration(duration)
} else if _, err := time.ParseDuration(parameters[i]); err == nil {
// If the original parameter was a duration string (like "48h"), format the resolved value
// as a duration string too so it matches and doesn't show in parentheses
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
resolvedStrings[i] = formatDuration(duration)
} else {
// Format as integer
resolvedStrings[i] = strconv.Itoa(int(resolvedParameters[i]))
}
}
return prettify(parameters, resolvedStrings, operator)
}
// formatDuration formats a duration in a clean, human-readable way by removing unnecessary zero components.
// For example: 336h0m0s becomes 336h, 1h30m0s becomes 1h30m, but 1h0m15s stays as 1h0m15s.
// Truncates to whole seconds to avoid decimal values like 7353h5m54.67s.
func formatDuration(d time.Duration) string {
// Truncate to whole seconds to avoid decimal seconds
d = d.Truncate(time.Second)
s := d.String()
// Special case: if duration is zero, return "0s"
if s == "0s" {
return "0s"
}
// Remove trailing "0s" if present
if strings.HasSuffix(s, "0s") {
s = strings.TrimSuffix(s, "0s")
// Remove trailing "0m" if present after removing "0s"
if strings.HasSuffix(s, "0m") {
s = strings.TrimSuffix(s, "0m")
}
}
return s
}
// prettify returns a string representation of a condition with its parameters resolved between parentheses
func prettify(parameters []string, resolvedParameters []string, operator string) string {
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
// we'll return the resolvedParameters as-is.
if strings.HasSuffix(resolvedParameters[0], InvalidConditionElementSuffix) || strings.HasSuffix(resolvedParameters[1], InvalidConditionElementSuffix) {
return resolvedParameters[0] + " " + operator + " " + resolvedParameters[1]
}
// If using the pattern function, truncate the parameter it's being compared to if said parameter is long enough
// Handle pattern function truncation first
if strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
resolvedParameters[1] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[1])
}
if strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
resolvedParameters[0] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[0])
}
// First element is a placeholder
if parameters[0] != resolvedParameters[0] && parameters[1] == resolvedParameters[1] {
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1]
// Determine the state of each parameter
leftChanged := parameters[0] != resolvedParameters[0]
rightChanged := parameters[1] != resolvedParameters[1]
leftInvalid := resolvedParameters[0] == parameters[0]+" "+InvalidConditionElementSuffix
rightInvalid := resolvedParameters[1] == parameters[1]+" "+InvalidConditionElementSuffix
// Build the output based on what was resolved
var left, right string
// Format left side
if leftChanged && !leftInvalid {
left = parameters[0] + " (" + resolvedParameters[0] + ")"
} else if leftInvalid {
left = resolvedParameters[0] // Already has (INVALID)
} else {
left = parameters[0] // Unchanged
}
// Second element is a placeholder
if parameters[0] == resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
return parameters[0] + " " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
// Format right side
if rightChanged && !rightInvalid {
right = parameters[1] + " (" + resolvedParameters[1] + ")"
} else if rightInvalid {
right = resolvedParameters[1] // Already has (INVALID)
} else {
right = parameters[1] // Unchanged
}
// Both elements are placeholders...?
if parameters[0] != resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
}
// Neither elements are placeholders
return parameters[0] + " " + operator + " " + parameters[1]
return left + " " + operator + " " + right
}
+78 -2
View File
@@ -6,6 +6,8 @@ import (
"strconv"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/gontext"
)
func TestCondition_Validate(t *testing.T) {
@@ -474,7 +476,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000",
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (336h) > 2419200000",
},
{
Name: "certificate-expiration-greater-than-duration",
@@ -488,7 +490,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
Result: &Result{CertificateExpiration: 24 * time.Hour},
ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (24h) > 48h",
},
{
Name: "no-placeholders",
@@ -777,3 +779,77 @@ func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
t.Error("condition was invalid, result should've had an error")
}
}
func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {
// Test case: Suite endpoint with invalid context placeholder
// This should display the original placeholder names with resolved values
condition := Condition("[STATUS] == [CONTEXT].expected_statusz")
result := &Result{HTTPStatus: 200}
ctx := gontext.New(map[string]interface{}{
// Note: expected_statusz is not in the context (typo - should be expected_status)
"expected_status": 200,
"max_response_time": 5000,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default)
if success {
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
}
if len(result.ConditionResults) == 0 {
t.Fatal("No condition results found")
}
actualDisplay := result.ConditionResults[0].Condition
// The expected format should preserve the placeholder names
expectedDisplay := "[STATUS] (200) == [CONTEXT].expected_statusz (INVALID)"
if actualDisplay != expectedDisplay {
t.Errorf("Incorrect condition display for failed context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
}
}
func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {
// Test case: Suite endpoint with valid context placeholder
condition := Condition("[STATUS] == [CONTEXT].expected_status")
result := &Result{HTTPStatus: 200}
ctx := gontext.New(map[string]interface{}{
"expected_status": 200,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx)
if !success {
t.Error("Condition should have succeeded")
}
if len(result.ConditionResults) == 0 {
t.Fatal("No condition results found")
}
actualDisplay := result.ConditionResults[0].Condition
// For successful conditions, just the original condition is shown
expectedDisplay := "[STATUS] == [CONTEXT].expected_status"
if actualDisplay != expectedDisplay {
t.Errorf("Incorrect condition display for successful context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
}
}
func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {
// Test case: One valid placeholder, one invalid
// Note: For numerical comparisons, invalid placeholders that can't be parsed as numbers
// default to 0 due to sanitizeAndResolveNumericalWithContext's behavior
condition := Condition("[RESPONSE_TIME] < [CONTEXT].invalid_key")
result := &Result{Duration: 100 * 1000000} // 100ms in nanoseconds
ctx := gontext.New(map[string]interface{}{
"valid_key": 5000,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx)
if success {
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
}
if len(result.ConditionResults) == 0 {
t.Fatal("No condition results found")
}
actualDisplay := result.ConditionResults[0].Condition
// For numerical comparisons, invalid context placeholders become 0
expectedDisplay := "[RESPONSE_TIME] (100) < [CONTEXT].invalid_key (0)"
if actualDisplay != expectedDisplay {
t.Errorf("Incorrect condition display\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
}
}
+35 -3
View File
@@ -50,6 +50,7 @@ const (
TypeSTARTTLS Type = "STARTTLS"
TypeTLS Type = "TLS"
TypeHTTP Type = "HTTP"
TypeGRPC Type = "GRPC"
TypeWS Type = "WEBSOCKET"
TypeSSH Type = "SSH"
TypeUNKNOWN Type = "UNKNOWN"
@@ -177,6 +178,8 @@ func (e *Endpoint) Type() Type {
return TypeTLS
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
return TypeHTTP
case strings.HasPrefix(e.URL, "grpc://") || strings.HasPrefix(e.URL, "grpcs://"):
return TypeGRPC
case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
return TypeWS
case strings.HasPrefix(e.URL, "ssh://"):
@@ -353,6 +356,9 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
}
result.port = ""
}
if processedEndpoint.UIConfig.HideErrors {
result.Errors = nil
}
if processedEndpoint.UIConfig.HideConditions {
result.ConditionResults = nil
}
@@ -498,7 +504,7 @@ func (e *Endpoint) call(result *Result) {
result.Duration = time.Since(startTime)
} else if endpointType == TypeSSH {
// If there's no username/password specified, attempt to validate just the SSH banner
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0) {
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
@@ -514,12 +520,30 @@ func (e *Endpoint) call(result *Result) {
result.AddError(err.Error())
return
}
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
var output []byte
result.Success, result.HTTPStatus, output, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
// Only store the output in result.Body if there's a condition that uses the BodyPlaceholder
if e.needsToReadBody() {
result.Body = output
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeGRPC {
useTLS := strings.HasPrefix(e.URL, "grpcs://")
address := strings.TrimPrefix(strings.TrimPrefix(e.URL, "grpcs://"), "grpc://")
connected, status, err, duration := client.PerformGRPCHealthCheck(address, useTLS, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Connected = connected
result.Duration = duration
if e.needsToReadBody() {
result.Body = []byte(fmt.Sprintf("{\"status\":\"%s\"}", status))
}
} else {
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
result.Duration = time.Since(startTime)
@@ -565,13 +589,21 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
return request
}
// needsToReadBody checks if there's any condition that requires the response Body to be read
// needsToReadBody checks if there's any condition or store mapping that requires the response Body to be read
func (e *Endpoint) needsToReadBody() bool {
for _, condition := range e.Conditions {
if condition.hasBodyPlaceholder() {
return true
}
}
// Check store values for body placeholders
if e.Store != nil {
for _, value := range e.Store {
if strings.Contains(value, BodyPlaceholder) {
return true
}
}
}
return false
}
+199
View File
@@ -914,6 +914,40 @@ func TestEndpoint_needsToReadBody(t *testing.T) {
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
// Test store configuration with body placeholder
storeWithBodyPlaceholder := map[string]string{
"token": "[BODY].accessToken",
}
if !(&Endpoint{
Conditions: []Condition{statusCondition},
Store: storeWithBodyPlaceholder,
}).needsToReadBody() {
t.Error("expected true when store has body placeholder, got false")
}
// Test store configuration without body placeholder
storeWithoutBodyPlaceholder := map[string]string{
"status": "[STATUS]",
}
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: storeWithoutBodyPlaceholder,
}).needsToReadBody() {
t.Error("expected false when store has no body placeholder, got true")
}
// Test empty store
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: map[string]string{},
}).needsToReadBody() {
t.Error("expected false when store is empty, got true")
}
// Test nil store
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: nil,
}).needsToReadBody() {
t.Error("expected false when store is nil, got true")
}
}
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {
@@ -1414,3 +1448,168 @@ func TestEndpoint_preprocessWithContext(t *testing.T) {
})
}
}
func TestEndpoint_HideUIFeatures(t *testing.T) {
defer client.InjectHTTPClient(nil)
tests := []struct {
name string
endpoint Endpoint
mockResponse test.MockRoundTripper
checkHostname bool
expectHostname string
checkErrors bool
expectErrors bool
checkConditions bool
expectConditions bool
checkErrorContent string
}{
{
name: "hide-conditions",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
UIConfig: &ui.Config{HideConditions: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`))}
}),
checkConditions: true,
expectConditions: false,
},
{
name: "hide-hostname",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{HideHostname: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
checkHostname: true,
expectHostname: "",
},
{
name: "hide-url-in-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideURL: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: true,
checkErrorContent: "<redacted>",
},
{
name: "hide-port-in-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com:9999/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HidePort: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: true,
checkErrorContent: "<redacted>",
},
{
name: "hide-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideErrors: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: false,
},
{
name: "dont-resolve-failed-conditions",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{DontResolveFailedConditions: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
checkConditions: true,
expectConditions: true,
},
{
name: "multiple-hide-features",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{HideConditions: true, HideHostname: true, HideErrors: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
checkConditions: true,
expectConditions: false,
checkHostname: true,
expectHostname: "",
checkErrors: true,
expectErrors: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.mockResponse != nil {
mockClient := &http.Client{Transport: tt.mockResponse}
if tt.endpoint.ClientConfig != nil && tt.endpoint.ClientConfig.Timeout > 0 {
mockClient.Timeout = tt.endpoint.ClientConfig.Timeout
}
client.InjectHTTPClient(mockClient)
} else {
client.InjectHTTPClient(nil)
}
err := tt.endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
}
result := tt.endpoint.EvaluateHealth()
if tt.checkHostname {
if result.Hostname != tt.expectHostname {
t.Errorf("Expected hostname '%s', got '%s'", tt.expectHostname, result.Hostname)
}
}
if tt.checkErrors {
hasErrors := len(result.Errors) > 0
if hasErrors != tt.expectErrors {
t.Errorf("Expected errors=%v, got errors=%v (actual errors: %v)", tt.expectErrors, hasErrors, result.Errors)
}
if tt.checkErrorContent != "" && len(result.Errors) > 0 {
found := false
for _, err := range result.Errors {
if strings.Contains(err, tt.checkErrorContent) {
found = true
break
}
}
if !found {
t.Errorf("Expected error to contain '%s', but got: %v", tt.checkErrorContent, result.Errors)
}
}
}
if tt.checkConditions {
hasConditions := result.ConditionResults != nil && len(result.ConditionResults) > 0
if hasConditions != tt.expectConditions {
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
}
}
})
}
}
+4
View File
@@ -16,6 +16,9 @@ type Config struct {
// HidePort whether to hide the port in the Result
HidePort bool `yaml:"hide-port"`
// HideErrors whether to hide the errors in the Result
HideErrors bool `yaml:"hide-errors"`
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
@@ -58,6 +61,7 @@ func GetDefaultConfig() *Config {
HideHostname: false,
HideURL: false,
HidePort: false,
HideErrors: false,
DontResolveFailedConditions: false,
HideConditions: false,
Badge: &Badge{
+3 -1
View File
@@ -15,5 +15,7 @@ func sanitize(s string) string {
s = strings.ReplaceAll(s, ",", "-")
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "#", "-")
s = strings.ReplaceAll(s, "+", "-")
s = strings.ReplaceAll(s, "&", "-")
return s
}
}
+15
View File
@@ -29,6 +29,21 @@ func TestConvertGroupAndNameToKey(t *testing.T) {
Name: "name",
ExpectedOutput: "_name",
},
{
GroupName: "API (v1)",
Name: "endpoint",
ExpectedOutput: "api-(v1)_endpoint",
},
{
GroupName: "website (admin)",
Name: "test",
ExpectedOutput: "website-(admin)_test",
},
{
GroupName: "search",
Name: "query&filter",
ExpectedOutput: "search_query-filter",
},
}
for _, scenario := range scenarios {
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
+10 -10
View File
@@ -31,17 +31,17 @@ var (
//
// Uses UTC by default.
type Config struct {
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
Timezone string `yaml:"timezone"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
Start string `yaml:"start,omitempty"` // Time at which the maintenance period starts (e.g. 23:00)
Duration time.Duration `yaml:"duration,omitempty"` // Duration of the maintenance period (e.g. 4h)
Timezone string `yaml:"timezone,omitempty"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
// Every is a list of days of the week during which maintenance period applies.
// See longDayNames for list of valid values.
// Every day if empty.
Every []string `yaml:"every"`
Every []string `yaml:"every,omitempty"`
TimezoneLocation *time.Location // Timezone in location format which the maintenance period is configured
timezoneLocation *time.Location
durationToStartFromMidnight time.Duration
}
@@ -90,13 +90,13 @@ func (c *Config) ValidateAndSetDefaults() error {
return errInvalidMaintenanceDuration
}
if c.Timezone != "" {
c.TimezoneLocation, err = time.LoadLocation(c.Timezone)
c.timezoneLocation, err = time.LoadLocation(c.Timezone)
if err != nil {
return fmt.Errorf("%w: %w", errInvalidTimezone, err)
}
} else {
c.Timezone = "UTC"
c.TimezoneLocation = time.UTC
c.timezoneLocation = time.UTC
}
return nil
}
@@ -107,8 +107,8 @@ func (c *Config) IsUnderMaintenance() bool {
return false
}
now := time.Now()
if c.TimezoneLocation != nil {
now = now.In(c.TimezoneLocation)
if c.timezoneLocation != nil {
now = now.In(c.timezoneLocation)
}
adjustedDate := now.Day()
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
+12
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -175,10 +176,12 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
return nil, nil
}
storedValues := make(map[string]interface{})
var extractionErrors []string
for contextKey, placeholder := range mappings {
value, err := extractValueForStorage(placeholder, result)
if err != nil {
// Continue storing other values even if one fails
extractionErrors = append(extractionErrors, fmt.Sprintf("%s: %v", contextKey, err))
storedValues[contextKey] = fmt.Sprintf("ERROR: %v", err)
continue
}
@@ -187,6 +190,10 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
}
storedValues[contextKey] = value
}
// Return an error if any values failed to extract
if len(extractionErrors) > 0 {
return storedValues, fmt.Errorf("failed to extract values: %s", strings.Join(extractionErrors, "; "))
}
return storedValues, nil
}
@@ -197,6 +204,11 @@ func extractValueForStorage(placeholder string, result *endpoint.Result) (interf
if err != nil {
return nil, err
}
// Check if the resolution resulted in an INVALID placeholder
// This happens when a path doesn't exist (e.g., [BODY].nonexistent)
if strings.HasSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix) {
return nil, fmt.Errorf("invalid path: %s", strings.TrimSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix))
}
// Try to parse as number or boolean to store as proper types
// Try int first for whole numbers
if num, err := strconv.ParseInt(resolved, 10, 64); err == nil {
+45
View File
@@ -1,6 +1,7 @@
package suite
import (
"strings"
"testing"
"time"
@@ -240,6 +241,50 @@ func TestStoreResultValues(t *testing.T) {
}
}
func TestStoreResultValuesWithInvalidPath(t *testing.T) {
ctx := gontext.New(map[string]interface{}{})
result := &endpoint.Result{
HTTPStatus: 200,
Body: []byte(`{"data": {"name": "john"}}`),
}
// Define store mappings with invalid paths
mappings := map[string]string{
"valid_status": "[STATUS]",
"invalid_token": "[BODY].accessToken", // This path doesn't exist
"invalid_nested": "[BODY].user.id.invalid", // This nested path doesn't exist
}
// Store values - should return error for invalid paths
stored, err := StoreResultValues(ctx, mappings, result)
if err == nil {
t.Fatal("Expected error when storing invalid paths, got nil")
}
// Check that the error message contains information about the invalid paths
if !strings.Contains(err.Error(), "invalid_token") {
t.Errorf("Error should mention invalid_token, got: %v", err)
}
if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("Error should mention 'invalid path', got: %v", err)
}
// Verify that valid values were still stored
if stored["valid_status"] != int64(200) {
t.Errorf("Expected valid_status=200, got %v", stored["valid_status"])
}
// Verify that invalid values show error messages in stored map
if !strings.Contains(stored["invalid_token"].(string), "ERROR") {
t.Errorf("Expected invalid_token to contain ERROR, got %v", stored["invalid_token"])
}
// Verify that invalid values are NOT in context
_, err = ctx.Get("invalid_token")
if err == nil {
t.Error("Invalid token should not be stored in context")
}
// Verify that valid value IS in context
val, err := ctx.Get("valid_status")
if err != nil || val != int64(200) {
t.Errorf("Expected valid_status=200 in context, got %v, err=%v", val, err)
}
}
func TestSuite_ExecuteWithAlwaysRunEndpoints(t *testing.T) {
suite := &Suite{
Name: "test-suite",
+162
View File
@@ -0,0 +1,162 @@
package sshtunnel
import (
"fmt"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// Config represents the configuration for an SSH tunnel
type Config struct {
Type string `yaml:"type"`
Host string `yaml:"host"`
Port int `yaml:"port,omitempty"`
Username string `yaml:"username"`
PrivateKey string `yaml:"private-key,omitempty"`
Password string `yaml:"password,omitempty"`
}
// ValidateAndSetDefaults validates the SSH tunnel configuration and sets defaults
func (c *Config) ValidateAndSetDefaults() error {
if c.Type != "SSH" {
return fmt.Errorf("unsupported tunnel type: %s", c.Type)
}
if c.Host == "" {
return fmt.Errorf("host is required")
}
if c.Username == "" {
return fmt.Errorf("username is required")
}
if c.PrivateKey == "" && c.Password == "" {
return fmt.Errorf("either private-key or password is required")
}
if c.Port == 0 {
c.Port = 22
}
return nil
}
// SSHTunnel represents an SSH tunnel connection
type SSHTunnel struct {
config *Config
mu sync.RWMutex
client *ssh.Client
// Cached authentication methods to avoid reparsing private keys
authMethods []ssh.AuthMethod
}
// New creates a new SSH tunnel with the given configuration
func New(config *Config) *SSHTunnel {
tunnel := &SSHTunnel{
config: config,
}
// Parse authentication methods once during initialization to avoid
// expensive cryptographic operations on every connection attempt
if config.PrivateKey != "" {
if signer, err := ssh.ParsePrivateKey([]byte(config.PrivateKey)); err == nil {
tunnel.authMethods = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
// Note: We don't return error here to maintain backward compatibility.
// Invalid keys will be caught during first connection attempt.
} else if config.Password != "" {
tunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}
}
return tunnel
}
// Connect establishes the SSH connection
func (t *SSHTunnel) Connect() error {
t.mu.Lock()
defer t.mu.Unlock()
return t.connectUnsafe()
}
// connectUnsafe establishes the SSH connection without acquiring locks
// Must be called with t.mu.Lock() already held
func (t *SSHTunnel) connectUnsafe() error {
// Use cached authentication methods to avoid expensive crypto operations
if len(t.authMethods) == 0 {
return fmt.Errorf("no authentication method available")
}
config := &ssh.ClientConfig{
User: t.config.Username,
Timeout: 30 * time.Second,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Skip host key verification
Auth: t.authMethods, // Use pre-parsed authentication
}
// Connect to SSH server
addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("SSH connection failed: %w", err)
}
t.client = client
return nil
}
// Close closes the SSH connection
func (t *SSHTunnel) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.client != nil {
err := t.client.Close()
t.client = nil
return err
}
return nil
}
// Dial creates a connection through the SSH tunnel
func (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {
t.mu.RLock()
client := t.client
t.mu.RUnlock()
// Ensure we have an SSH connection
if client == nil {
// Use write lock to prevent race condition during connection
t.mu.Lock()
// Double-check client after acquiring lock
if t.client == nil {
if err := t.connectUnsafe(); err != nil {
t.mu.Unlock()
return nil, err
}
}
client = t.client
t.mu.Unlock()
}
// Attempt dial with exponential backoff retry
const maxRetries = 3
const baseDelay = 500 * time.Millisecond
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff: 500ms, 1s, 2s
delay := baseDelay << (attempt - 1)
time.Sleep(delay)
// Close stale connection and reconnect
t.mu.Lock()
if t.client != nil {
_ = t.client.Close()
t.client = nil
}
if err := t.connectUnsafe(); err != nil {
t.mu.Unlock()
lastErr = fmt.Errorf("reconnect attempt %d failed: %w", attempt, err)
continue
}
client = t.client
t.mu.Unlock()
}
conn, err := client.Dial(network, addr)
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, fmt.Errorf("SSH tunnel dial failed after %d attempts: %w", maxRetries, lastErr)
}
@@ -0,0 +1,158 @@
package sshtunnel
import (
"testing"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string
}{
{
name: "valid SSH config with private key",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
},
wantErr: false,
},
{
name: "valid SSH config with password",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
wantErr: false,
},
{
name: "valid SSH config with custom port",
config: &Config{
Type: "SSH",
Host: "example.com",
Port: 2222,
Username: "test",
Password: "secret",
},
wantErr: false,
},
{
name: "sets default port 22",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
wantErr: false,
},
{
name: "invalid type",
config: &Config{
Type: "INVALID",
Host: "example.com",
Username: "test",
Password: "secret",
},
wantErr: true,
errMsg: "unsupported tunnel type: INVALID",
},
{
name: "missing host",
config: &Config{
Type: "SSH",
Username: "test",
Password: "secret",
},
wantErr: true,
errMsg: "host is required",
},
{
name: "missing username",
config: &Config{
Type: "SSH",
Host: "example.com",
Password: "secret",
},
wantErr: true,
errMsg: "username is required",
},
{
name: "missing authentication",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
},
wantErr: true,
errMsg: "either private-key or password is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalPort := tt.config.Port
err := tt.config.ValidateAndSetDefaults()
if tt.wantErr {
if err == nil {
t.Errorf("ValidateAndSetDefaults() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
return
}
// Check that default port is set
if originalPort == 0 && tt.config.Port != 22 {
t.Errorf("ValidateAndSetDefaults() expected default port 22, got %d", tt.config.Port)
}
})
}
}
func TestNew(t *testing.T) {
config := &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
}
tunnel := New(config)
if tunnel == nil {
t.Error("New() returned nil")
return
}
if tunnel.config != config {
t.Error("New() did not set config correctly")
}
}
func TestSSHTunnel_Close(t *testing.T) {
config := &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
}
tunnel := New(config)
// Test closing when no client is set
err := tunnel.Close()
if err != nil {
t.Errorf("Close() with no client returned error: %v", err)
}
// Test closing multiple times
err = tunnel.Close()
if err != nil {
t.Errorf("Close() called twice returned error: %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package tunneling
import (
"fmt"
"strings"
"sync"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
)
// Config represents the tunneling configuration
type Config struct {
// Tunnels is a map of SSH tunnel configurations in which the key is the name of the tunnel
Tunnels map[string]*sshtunnel.Config `yaml:",inline"`
mu sync.RWMutex `yaml:"-"`
connections map[string]*sshtunnel.SSHTunnel `yaml:"-"`
}
// ValidateAndSetDefaults validates the tunneling configuration and sets defaults
func (tc *Config) ValidateAndSetDefaults() error {
if tc.connections == nil {
tc.connections = make(map[string]*sshtunnel.SSHTunnel)
}
for name, config := range tc.Tunnels {
if err := config.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("tunnel '%s': %w", name, err)
}
}
return nil
}
// GetTunnel returns the SSH tunnel for the given name, creating it if necessary
func (tc *Config) GetTunnel(name string) (*sshtunnel.SSHTunnel, error) {
if name == "" {
return nil, fmt.Errorf("tunnel name cannot be empty")
}
tc.mu.Lock()
defer tc.mu.Unlock()
// Check if tunnel already exists
if tunnel, exists := tc.connections[name]; exists {
return tunnel, nil
}
// Get config for this tunnel
config, exists := tc.Tunnels[name]
if !exists {
return nil, fmt.Errorf("tunnel '%s' not found in configuration", name)
}
// Create and store new tunnel
tunnel := sshtunnel.New(config)
tc.connections[name] = tunnel
return tunnel, nil
}
// Close closes all SSH tunnel connections
func (tc *Config) Close() error {
tc.mu.Lock()
defer tc.mu.Unlock()
var errors []string
for name, tunnel := range tc.connections {
if err := tunnel.Close(); err != nil {
errors = append(errors, fmt.Sprintf("tunnel '%s': %v", name, err))
}
delete(tc.connections, name)
}
if len(errors) > 0 {
return fmt.Errorf("failed to close tunnels: %s", strings.Join(errors, ", "))
}
return nil
}
+191
View File
@@ -0,0 +1,191 @@
package tunneling
import (
"testing"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string
}{
{
name: "valid config with SSH tunnel",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
wantErr: false,
},
{
name: "multiple valid tunnels",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"tunnel1": {
Type: "SSH",
Host: "host1.com",
Username: "user1",
PrivateKey: "key1",
},
"tunnel2": {
Type: "SSH",
Host: "host2.com",
Username: "user2",
Password: "pass2",
},
},
},
wantErr: false,
},
{
name: "invalid tunnel config",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"invalid": {
Type: "INVALID",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
wantErr: true,
errMsg: "tunnel 'invalid': unsupported tunnel type: INVALID",
},
{
name: "missing host in tunnel",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"nohost": {
Type: "SSH",
Username: "test",
Password: "secret",
},
},
},
wantErr: true,
errMsg: "tunnel 'nohost': host is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.ValidateAndSetDefaults()
if tt.wantErr {
if err == nil {
t.Errorf("ValidateAndSetDefaults() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
return
}
// Check that connections map is initialized
if tt.config != nil && tt.config.connections == nil {
t.Error("ValidateAndSetDefaults() did not initialize connections map")
}
})
}
}
func TestConfig_GetTunnel(t *testing.T) {
config := &Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
}
err := config.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
}
// Test getting existing tunnel
tunnel1, err := config.GetTunnel("test")
if err != nil {
t.Errorf("GetTunnel() error = %v", err)
return
}
if tunnel1 == nil {
t.Error("GetTunnel() returned nil tunnel")
return
}
// Test getting same tunnel again (should return same instance)
tunnel2, err := config.GetTunnel("test")
if err != nil {
t.Errorf("GetTunnel() second call error = %v", err)
return
}
if tunnel1 != tunnel2 {
t.Error("GetTunnel() should return same instance for same tunnel name")
}
// Test getting non-existent tunnel
_, err = config.GetTunnel("nonexistent")
if err == nil {
t.Error("GetTunnel() expected error for non-existent tunnel")
return
}
expectedErr := "tunnel 'nonexistent' not found in configuration"
if err.Error() != expectedErr {
t.Errorf("GetTunnel() error = %v, want %v", err.Error(), expectedErr)
}
}
func TestConfig_Close(t *testing.T) {
// Test closing config with tunnels
config := &Config{
Tunnels: map[string]*sshtunnel.Config{
"test1": {
Type: "SSH",
Host: "example1.com",
Username: "test",
Password: "secret",
},
"test2": {
Type: "SSH",
Host: "example2.com",
Username: "test",
Password: "secret",
},
},
}
err := config.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
}
// Create some tunnels
_, err = config.GetTunnel("test1")
if err != nil {
t.Fatalf("GetTunnel() failed: %v", err)
}
_, err = config.GetTunnel("test2")
if err != nil {
t.Fatalf("GetTunnel() failed: %v", err)
}
// Test closing
err = config.Close()
if err != nil {
t.Errorf("Close() returned error: %v", err)
}
// Verify connections map is empty
if len(config.connections) != 0 {
t.Errorf("Close() did not clear connections map, got %d connections", len(config.connections))
}
}
+45 -32
View File
@@ -5,58 +5,71 @@ go 1.24.4
toolchain go1.24.7
require (
code.gitea.io/sdk/gitea v0.21.0
code.gitea.io/sdk/gitea v0.22.0
github.com/TwiN/deepmerge v0.2.2
github.com/TwiN/g8/v2 v2.0.0
github.com/TwiN/gocache/v2 v2.4.0
github.com/TwiN/health v1.6.0
github.com/TwiN/logr v0.3.1
github.com/TwiN/whois v1.1.11
github.com/aws/aws-sdk-go v1.55.8
github.com/coreos/go-oidc/v3 v3.14.1
github.com/gofiber/fiber/v2 v2.52.8
github.com/TwiN/whois v1.2.0
github.com/aws/aws-sdk-go-v2 v1.39.2
github.com/aws/aws-sdk-go-v2/config v1.31.12
github.com/aws/aws-sdk-go-v2/credentials v1.18.16
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5
github.com/coreos/go-oidc/v3 v3.16.0
github.com/gofiber/fiber/v2 v2.52.9
github.com/google/go-github/v48 v48.2.0
github.com/google/uuid v1.6.0
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.68
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.23.0
github.com/prometheus/client_golang v1.23.2
github.com/registrobr/rdap v1.1.8
github.com/valyala/fasthttp v1.64.0
github.com/valyala/fasthttp v1.67.0
github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.40.0
golang.org/x/net v0.42.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.16.0
google.golang.org/api v0.242.0
golang.org/x/crypto v0.43.0
golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.32.0
golang.org/x/sync v0.17.0
google.golang.org/api v0.252.0
google.golang.org/grpc v1.75.1
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.38.2
modernc.org/sqlite v1.39.1
)
require (
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/42wim/httpsig v1.2.2 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect
github.com/aws/smithy-go v1.23.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
@@ -65,27 +78,27 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+108 -87
View File
@@ -1,13 +1,13 @@
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA=
github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0=
code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
github.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
@@ -18,21 +18,46 @@ github.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM=
github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
github.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
github.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw=
github.com/TwiN/whois v1.1.11 h1:lYiYgPRSQ3kH8sQfgHcBY/uNSGGvWPRikEjn+LJZ9+Q=
github.com/TwiN/whois v1.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/TwiN/whois v1.2.0 h1:/Z22SrS3Z0FQgMl1+4bKSu9UmEQTfGx9i9J4Hn18eQk=
github.com/TwiN/whois v1.2.0/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I=
github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8=
github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8=
github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5 h1:NwOeuOFrWoh4xWKINrmaAK4Vh75jmmY0RAuNjQ6W5Es=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.5/go.mod h1:m3BsMJZD0eqjGIniBzwrNUqG9ZUPquC4hY9FyE2qNFo=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
@@ -43,15 +68,15 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -72,16 +97,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo=
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -109,12 +130,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=
@@ -126,13 +147,12 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og=
github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA=
github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac=
github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -144,18 +164,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
@@ -163,8 +185,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
@@ -174,8 +196,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -185,18 +207,18 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -210,8 +232,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -220,8 +242,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -231,28 +253,30 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI=
google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -260,23 +284,20 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -285,8 +306,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+36
View File
@@ -59,6 +59,7 @@ func stop(cfg *config.Config) {
watchdog.Shutdown(cfg)
controller.Shutdown()
metrics.UnregisterPrometheusMetrics()
closeTunnels(cfg)
}
func save() {
@@ -182,11 +183,46 @@ func initializeStorage(cfg *config.Config) {
}
}
}
// Load persisted triggered alerts for suite endpoints
for _, suite := range cfg.Suites {
for _, ep := range suite.Endpoints {
var checksums []string
for _, alert := range ep.Alerts {
if alert.IsEnabled() {
checksums = append(checksums, alert.Checksum())
}
}
numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums)
if numberOfTriggeredAlertsDeleted > 0 {
logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for suite endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key())
}
for _, alert := range ep.Alerts {
exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert)
if err != nil {
logr.Errorf("[main.initializeStorage] Failed to get triggered alert for suite endpoint with key=%s: %s", ep.Key(), err.Error())
continue
}
if exists {
alert.Triggered, alert.ResolveKey = true, resolveKey
ep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold
numberOfPersistedTriggeredAlertsLoaded++
}
}
}
}
if numberOfPersistedTriggeredAlertsLoaded > 0 {
logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded)
}
}
func closeTunnels(cfg *config.Config) {
if cfg.Tunneling != nil {
if err := cfg.Tunneling.Close(); err != nil {
logr.Errorf("[main.closeTunnels] Error closing SSH tunnels: %v", err)
}
}
}
func listenToConfigurationFileChanges(cfg *config.Config) {
for {
time.Sleep(30 * time.Second)
+14
View File
@@ -17,6 +17,7 @@ var (
resultConnectedTotal *prometheus.CounterVec
resultCodeTotal *prometheus.CounterVec
resultCertificateExpirationSeconds *prometheus.GaugeVec
resultDomainExpirationSeconds *prometheus.GaugeVec
resultEndpointSuccess *prometheus.GaugeVec
// Suite metrics
@@ -51,6 +52,9 @@ func UnregisterPrometheusMetrics() {
if resultCertificateExpirationSeconds != nil {
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
}
if resultDomainExpirationSeconds != nil {
currentRegisterer.Unregister(resultDomainExpirationSeconds)
}
if resultEndpointSuccess != nil {
currentRegisterer.Unregister(resultEndpointSuccess)
}
@@ -119,6 +123,13 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultCertificateExpirationSeconds)
resultDomainExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_domain_expiration_seconds",
Help: "Number of seconds until the domain expires",
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultDomainExpirationSeconds)
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_endpoint_success",
@@ -178,6 +189,9 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
if result.CertificateExpiration != 0 {
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
}
if result.DomainExpiration != 0 {
resultDomainExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.DomainExpiration.Seconds())
}
if result.Success {
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
} else {
+13
View File
@@ -48,6 +48,9 @@ func TestInitializePrometheusMetrics(t *testing.T) {
if resultCertificateExpirationSeconds == nil {
t.Error("resultCertificateExpirationSeconds metric not initialized")
}
if resultDomainExpirationSeconds == nil {
t.Error("resultDomainExpirationSeconds metric not initialized")
}
if resultEndpointSuccess == nil {
t.Error("resultEndpointSuccess metric not initialized")
}
@@ -120,9 +123,11 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
},
Success: true,
CertificateExpiration: 49 * time.Hour,
DomainExpiration: 25 * time.Hour,
}, []string{})
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
@@ -140,6 +145,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 176400
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
# TYPE gatus_results_domain_expiration_seconds gauge
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 90000
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
@@ -154,9 +162,11 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
},
Success: false,
CertificateExpiration: 47 * time.Hour,
DomainExpiration: 24 * time.Hour,
}, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
@@ -175,6 +185,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
# TYPE gatus_results_domain_expiration_seconds gauge
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 86400
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
+3 -3
View File
@@ -26,9 +26,9 @@ type Config struct {
gate *g8.Gate
}
// IsValid returns whether the security configuration is valid or not
func (c *Config) IsValid() bool {
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.isValid())
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
func (c *Config) ValidateAndSetDefaults() bool {
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())
}
// RegisterHandlers registers all handlers required based on the security configuration
+3 -2
View File
@@ -9,12 +9,12 @@ import (
"golang.org/x/oauth2"
)
func TestConfig_IsValid(t *testing.T) {
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
c := &Config{
Basic: nil,
OIDC: nil,
}
if c.IsValid() {
if c.ValidateAndSetDefaults() {
t.Error("expected empty config to be valid")
}
}
@@ -65,6 +65,7 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) {
RedirectURL: "http://localhost:80/authorization-code/callback",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
SessionTTL: DefaultOIDCSessionTTL,
oauth2Config: oauth2.Config{},
verifier: nil,
}}
+18 -10
View File
@@ -13,21 +13,29 @@ import (
"golang.org/x/oauth2"
)
const (
DefaultOIDCSessionTTL = 8 * time.Hour
)
// OIDCConfig is the configuration for OIDC authentication
type OIDCConfig struct {
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
SessionTTL time.Duration `yaml:"session-ttl"` // e.g. 8h. Defaults to 8 hours
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
}
// isValid returns whether the basic security configuration is valid or not
func (c *OIDCConfig) isValid() bool {
// ValidateAndSetDefaults returns whether the OIDC configuration is valid and sets default values.
func (c *OIDCConfig) ValidateAndSetDefaults() bool {
if c.SessionTTL <= 0 {
c.SessionTTL = DefaultOIDCSessionTTL
}
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
}
@@ -131,12 +139,12 @@ func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { /
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
// At this point, the user has been confirmed. All that's left to do is create a session.
sessionID := uuid.NewString()
sessions.SetWithTTL(sessionID, idToken.Subject, time.Hour)
sessions.SetWithTTL(sessionID, idToken.Subject, c.SessionTTL)
http.SetCookie(w, &http.Cookie{
Name: cookieNameSession,
Value: sessionID,
Path: "/",
MaxAge: int(time.Hour.Seconds()),
MaxAge: int(c.SessionTTL.Seconds()),
SameSite: http.SameSiteStrictMode,
})
}
+22 -2
View File
@@ -4,11 +4,12 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
func TestOIDCConfig_isValid(t *testing.T) {
func TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) {
c := &OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
@@ -16,10 +17,14 @@ func TestOIDCConfig_isValid(t *testing.T) {
ClientSecret: "client-secret",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
SessionTTL: 0, // Not set! ValidateAndSetDefaults should set it to DefaultOIDCSessionTTL
}
if !c.isValid() {
if !c.ValidateAndSetDefaults() {
t.Error("OIDCConfig should be valid")
}
if c.SessionTTL != DefaultOIDCSessionTTL {
t.Error("expected SessionTTL to be set to DefaultOIDCSessionTTL")
}
}
func TestOIDCConfig_callbackHandler(t *testing.T) {
@@ -68,3 +73,18 @@ func TestOIDCConfig_setSessionCookie(t *testing.T) {
t.Error("expected cookie to be set")
}
}
func TestOIDCConfig_setSessionCookieWithCustomTTL(t *testing.T) {
customTTL := 30 * time.Minute
c := &OIDCConfig{SessionTTL: customTTL}
responseRecorder := httptest.NewRecorder()
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
cookies := responseRecorder.Result().Cookies()
if len(cookies) == 0 {
t.Error("expected cookie to be set")
}
sessionCookie := cookies[0]
if sessionCookie.MaxAge != int(customTTL.Seconds()) {
t.Errorf("expected cookie MaxAge to be %d, but was %d", int(customTTL.Seconds()), sessionCookie.MaxAge)
}
}
+10 -3
View File
@@ -289,6 +289,7 @@ func (s *Store) InsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Res
// Get the success value of the previous result
var lastResultSuccess bool
if lastResultSuccess, err = s.getLastEndpointResultSuccessValue(tx, endpointID); err != nil {
// Silently fail
logr.Errorf("[sql.InsertEndpointResult] Failed to retrieve outcome of previous result for endpoint with key=%s: %s", ep.Key(), err.Error())
} else {
// If we managed to retrieve the outcome of the previous result, we'll compare it with the new result.
@@ -758,13 +759,19 @@ func (s *Store) getEndpointIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64
}
func (s *Store) getEndpointEventsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (events []*endpoint.Event, err error) {
// We need to get the most recent events, but return them in chronological order (oldest to newest)
// First, get the most recent events using a subquery, then order them chronologically
rows, err := tx.Query(
`
SELECT event_type, event_timestamp
FROM endpoint_events
WHERE endpoint_id = $1
FROM (
SELECT event_type, event_timestamp, endpoint_event_id
FROM endpoint_events
WHERE endpoint_id = $1
ORDER BY endpoint_event_id DESC
LIMIT $2 OFFSET $3
) AS recent_events
ORDER BY endpoint_event_id ASC
LIMIT $2 OFFSET $3
`,
endpointID,
pageSize,
+56
View File
@@ -886,3 +886,59 @@ func TestStore_HasEndpointStatusNewerThan(t *testing.T) {
t.Error("expected not to have a newer status in the future")
}
}
// TestEventOrderingFix specifically tests the SQL ordering fix for issue #1040
// This test verifies that getEndpointEventsByEndpointID returns the most recent events
// in chronological order (oldest to newest)
func TestEventOrderingFix(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/test.db", false, 100, 100)
defer store.Close()
ep := &endpoint.Endpoint{
Name: "ordering-test",
Group: "test",
URL: "https://example.com",
}
// Create many events over time
baseTime := time.Now().Add(-100 * time.Hour) // Start 100 hours ago
for i := 0; i < 50; i++ {
result := &endpoint.Result{
Success: i%2 == 0, // Alternate between true/false to create events
Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
}
err := store.InsertEndpointResult(ep, result)
if err != nil {
t.Fatalf("Failed to insert result %d: %v", i, err)
}
}
// Now retrieve events with pagination to test the ordering
tx, _ := store.db.Begin()
endpointID, _, _, _ := store.getEndpointIDGroupAndNameByKey(tx, ep.Key())
// Get the first page (should get the MOST RECENT events, but in chronological order)
events, err := store.getEndpointEventsByEndpointID(tx, endpointID, 1, 10)
tx.Commit()
if err != nil {
t.Fatalf("Failed to get events: %v", err)
}
if len(events) != 10 {
t.Errorf("Expected 10 events, got %d", len(events))
}
// Verify the events are in chronological order (oldest to newest)
for i := 1; i < len(events); i++ {
if events[i].Timestamp.Before(events[i-1].Timestamp) {
t.Errorf("Events not in chronological order: event %d timestamp %v is before event %d timestamp %v",
i, events[i].Timestamp, i-1, events[i-1].Timestamp)
}
}
// Verify these are the most recent events
// The last event in the returned list should be close to "now" (within the last few events we created)
lastEventTime := events[len(events)-1].Timestamp
expectedRecentTime := baseTime.Add(49 * time.Hour) // The most recent event we created
timeDiff := expectedRecentTime.Sub(lastEventTime)
if timeDiff > 10*time.Hour { // Allow some margin for events
t.Errorf("Events are not the most recent ones. Last event time: %v, expected around: %v (diff: %v)",
lastEventTime, expectedRecentTime, timeDiff)
}
t.Logf("Successfully retrieved %d most recent events in chronological order", len(events))
t.Logf("First event: %s at %v", events[0].Type, events[0].Timestamp)
t.Logf("Last event: %s at %v", events[len(events)-1].Type, events[len(events)-1].Timestamp)
}
+1 -15
View File
@@ -12,7 +12,6 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -325,19 +324,6 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
},
},
},
{
Name: "jetbrainsspace",
AlertType: alert.TypeJetBrainsSpace,
AlertingConfig: &alerting.Config{
JetBrainsSpace: &jetbrainsspace.AlertProvider{
DefaultConfig: jetbrainsspace.Config{
Project: "foo",
ChannelID: "bar",
Token: "baz",
},
},
},
},
{
Name: "line",
AlertType: alert.TypeLine,
@@ -648,7 +634,7 @@ func TestHandleAlertingWithMinimumReminderInterval(t *testing.T) {
SuccessThreshold: 3,
SendOnResolved: &enabled,
Triggered: false,
MinimumReminderInterval: 1 * time.Second,
MinimumReminderInterval: 5 * time.Minute,
},
},
}
-1
View File
@@ -64,7 +64,6 @@ func executeEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []st
}
}
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
// TODO: Consider moving this after the monitoring lock is unlocked? I mean, how much noise can a single alerting provider cause...
HandleAlerting(ep, result, cfg.Alerting)
} else {
logr.Debug("[watchdog.executeEndpoint] Not handling alerting because currently in the maintenance window")
+2 -2
View File
@@ -50,6 +50,8 @@ func executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) {
if cfg.Metrics {
metrics.PublishMetricsForSuite(s, result, extraLabels)
}
// Store result
UpdateSuiteStatus(s, result)
// Handle alerting for suite endpoints
for i, ep := range s.Endpoints {
if i < len(result.EndpointResults) {
@@ -72,8 +74,6 @@ func executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) {
}
}
logr.Infof("[watchdog.executeSuite] Completed suite=%s; success=%v; errors=%d; duration=%v; endpoints_executed=%d/%d", s.Name, result.Success, len(result.Errors), result.Duration, len(result.EndpointResults), len(s.Endpoints))
// Store result in database
UpdateSuiteStatus(s, result)
}
// UpdateSuiteStatus persists the suite result in the database
+146 -464
View File
@@ -8,16 +8,19 @@
"name": "gatus",
"version": "4.0.0",
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@vueuse/core": "^13.6.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core-js": "^3.45.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"lucide-vue-next": "^0.539.0",
"radix-vue": "^1.9.17",
"marked": "^16.4.1",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {
@@ -1861,42 +1864,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@floating-ui/vue": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.8.tgz",
"integrity": "sha512-SNJAa1jbT8Gh1LvWw2uIIViLL0saV2bCY59ISCvJzhbut5DSb2H3LKUK49Xkd7SixTNHKX4LFu59nbwIXt9jjQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.3",
"@floating-ui/utils": "^0.2.10",
"vue-demi": ">=0.13.0"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -1912,30 +1879,6 @@
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@headlessui/vue": {
"version": "1.7.23",
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
"integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
"license": "MIT",
"dependencies": {
"@tanstack/vue-virtual": "^3.0.0-beta.60"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@heroicons/vue": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
"license": "MIT",
"peerDependencies": {
"vue": ">= 3"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -1974,24 +1917,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@internationalized/date": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.4.tgz",
"integrity": "sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
@@ -2063,6 +1988,12 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -2244,41 +2175,6 @@
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
"dev": true
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/vue-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz",
"integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@@ -2472,11 +2368,12 @@
"@types/node": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/ws": {
"version": "8.5.3",
@@ -3221,44 +3118,6 @@
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
"dev": true
},
"node_modules/@vueuse/core": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz",
"integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.6.0",
"@vueuse/shared": "13.6.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz",
"integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz",
"integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@webassemblyjs/ast": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -3670,18 +3529,6 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/array-flatten": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
@@ -4170,6 +4017,37 @@
"node": ">=4"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=2.8.0",
"date-fns": ">=2.0.0"
}
},
"node_modules/chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=4.0.0"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -5002,6 +4880,16 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -5146,12 +5034,6 @@
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
"dev": true
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -5298,6 +5180,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -6237,7 +6128,8 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"node_modules/fast-glob": {
"version": "3.2.11",
@@ -7798,6 +7690,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -9535,88 +9439,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/radix-vue": {
"version": "1.9.17",
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
"integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.7",
"@floating-ui/vue": "^1.1.0",
"@internationalized/date": "^3.5.4",
"@internationalized/number": "^3.5.3",
"@tanstack/vue-virtual": "^3.8.1",
"@vueuse/core": "^10.11.0",
"@vueuse/shared": "^10.11.0",
"aria-hidden": "^1.2.4",
"defu": "^6.1.4",
"fast-deep-equal": "^3.1.3",
"nanoid": "^5.0.7"
},
"peerDependencies": {
"vue": ">= 3.2.0"
}
},
"node_modules/radix-vue/node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/radix-vue/node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"license": "MIT",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/radix-vue/node_modules/nanoid": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -10821,6 +10643,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
@@ -11026,30 +10849,14 @@
}
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"node_modules/vue-chartjs": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-eslint-parser": {
@@ -13323,38 +13130,6 @@
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"dev": true
},
"@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"requires": {
"@floating-ui/utils": "^0.2.10"
}
},
"@floating-ui/dom": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
"integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
"requires": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"@floating-ui/vue": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.8.tgz",
"integrity": "sha512-SNJAa1jbT8Gh1LvWw2uIIViLL0saV2bCY59ISCvJzhbut5DSb2H3LKUK49Xkd7SixTNHKX4LFu59nbwIXt9jjQ==",
"requires": {
"@floating-ui/dom": "^1.7.3",
"@floating-ui/utils": "^0.2.10",
"vue-demi": ">=0.13.0"
}
},
"@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -13370,20 +13145,6 @@
"@hapi/hoek": "^9.0.0"
}
},
"@headlessui/vue": {
"version": "1.7.23",
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
"integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
"requires": {
"@tanstack/vue-virtual": "^3.0.0-beta.60"
}
},
"@heroicons/vue": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
"integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==",
"requires": {}
},
"@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -13407,22 +13168,6 @@
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"dev": true
},
"@internationalized/date": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
"requires": {
"@swc/helpers": "^0.5.0"
}
},
"@internationalized/number": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.4.tgz",
"integrity": "sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg==",
"requires": {
"@swc/helpers": "^0.5.0"
}
},
"@jridgewell/gen-mapping": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
@@ -13483,6 +13228,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"@leichtgewicht/ip-codec": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -13629,27 +13379,6 @@
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
"dev": true
},
"@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"requires": {
"tslib": "^2.8.0"
}
},
"@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="
},
"@tanstack/vue-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.12.tgz",
"integrity": "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==",
"requires": {
"@tanstack/virtual-core": "3.13.12"
}
},
"@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@@ -13840,10 +13569,11 @@
"@types/node": "*"
}
},
"@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
"@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"@types/ws": {
"version": "8.5.3",
@@ -14421,27 +14151,6 @@
"integrity": "sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==",
"dev": true
},
"@vueuse/core": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz",
"integrity": "sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==",
"requires": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.6.0",
"@vueuse/shared": "13.6.0"
}
},
"@vueuse/metadata": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz",
"integrity": "sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ=="
},
"@vueuse/shared": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz",
"integrity": "sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==",
"requires": {}
},
"@webassemblyjs/ast": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -14776,14 +14485,6 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"requires": {
"tslib": "^2.0.0"
}
},
"array-flatten": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
@@ -15123,6 +14824,26 @@
"supports-color": "^5.3.0"
}
},
"chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"requires": {
"@kurkle/color": "^0.3.0"
}
},
"chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"requires": {}
},
"chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
"requires": {}
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -15721,6 +15442,11 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
},
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -15822,11 +15548,6 @@
"integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
"dev": true
},
"defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="
},
"depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -15936,6 +15657,14 @@
"domelementtype": "^2.2.0"
}
},
"dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"requires": {
"@types/trusted-types": "^2.0.7"
}
},
"domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -16615,7 +16344,8 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"fast-glob": {
"version": "3.2.11",
@@ -17784,6 +17514,11 @@
"semver": "^6.0.0"
}
},
"marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="
},
"mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -18970,60 +18705,6 @@
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true
},
"radix-vue": {
"version": "1.9.17",
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
"integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==",
"requires": {
"@floating-ui/dom": "^1.6.7",
"@floating-ui/vue": "^1.1.0",
"@internationalized/date": "^3.5.4",
"@internationalized/number": "^3.5.3",
"@tanstack/vue-virtual": "^3.8.1",
"@vueuse/core": "^10.11.0",
"@vueuse/shared": "^10.11.0",
"aria-hidden": "^1.2.4",
"defu": "^6.1.4",
"fast-deep-equal": "^3.1.3",
"nanoid": "^5.0.7"
},
"dependencies": {
"@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
},
"@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"requires": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
}
},
"@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="
},
"@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"requires": {
"vue-demi": ">=0.14.8"
}
},
"nanoid": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="
}
}
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -19954,7 +19635,8 @@
"tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true
},
"type-check": {
"version": "0.4.0",
@@ -20092,10 +19774,10 @@
"@vue/shared": "3.5.18"
}
},
"vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"vue-chartjs": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"requires": {}
},
"vue-eslint-parser": {
+8 -1
View File
@@ -8,22 +8,29 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core-js": "^3.45.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"lucide-vue-next": "^0.539.0",
"marked": "^16.4.1",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@babel/eslint-parser": "^7.25.1",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-eslint": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.8",
"@vue/cli-service": "^5.0.8",
"@vue/compiler-sfc": "^3.5.18",
"autoprefixer": "^10.4.21",
"@babel/eslint-parser": "^7.25.1",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.28.0",
"postcss": "^8.5.6",
+40 -5
View File
@@ -148,7 +148,7 @@
</div>
<!-- Tooltip -->
<Tooltip :result="tooltip.result" :event="tooltip.event" />
<Tooltip :result="tooltip.result" :event="tooltip.event" :isPersistent="tooltipIsPersistent" />
</div>
</template>
@@ -173,6 +173,7 @@ const announcements = ref([])
const tooltip = ref({})
const mobileMenuOpen = ref(false)
const isOidcLoading = ref(false)
const tooltipIsPersistent = ref(false)
let configInterval = null
// Computed properties
@@ -196,21 +197,51 @@ const buttons = computed(() => {
const fetchConfig = async () => {
try {
const response = await fetch(`${SERVER_URL}/api/v1/config`, { credentials: 'include' })
retrievedConfig.value = true
if (response.status === 200) {
const data = await response.json()
config.value = data
announcements.value = data.announcements || []
}
retrievedConfig.value = true
} catch (error) {
console.error('Failed to fetch config:', error)
retrievedConfig.value = true
}
}
const showTooltip = (result, event) => {
tooltip.value = { result, event }
const showTooltip = (result, event, action = 'hover') => {
if (action === 'click') {
if (!result) {
// Deselecting
tooltip.value = {}
tooltipIsPersistent.value = false
} else {
// Selecting new data point
tooltip.value = { result, event }
tooltipIsPersistent.value = true
}
} else if (action === 'hover') {
// Only update tooltip on hover if not in persistent mode
if (!tooltipIsPersistent.value) {
tooltip.value = { result, event }
}
}
}
const handleDocumentClick = (event) => {
// Close persistent tooltip when clicking outside
if (tooltipIsPersistent.value) {
const tooltipElement = document.getElementById('tooltip')
// Check if click is on a data point bar or inside tooltip
const clickedDataPoint = event.target.closest('.flex-1.h-6, .flex-1.h-8')
if (tooltipElement && !tooltipElement.contains(event.target) && !clickedDataPoint) {
tooltip.value = {}
tooltipIsPersistent.value = false
// Emit event to clear selections in child components
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
}
}
}
// Fetch config on mount and set up interval
@@ -218,6 +249,8 @@ onMounted(() => {
fetchConfig()
// Refresh config every 10 minutes for announcements
configInterval = setInterval(fetchConfig, 600000)
// Add click listener for closing persistent tooltips
document.addEventListener('click', handleDocumentClick)
})
// Clean up interval on unmount
@@ -226,5 +259,7 @@ onUnmounted(() => {
clearInterval(configInterval)
configInterval = null
}
// Remove click listener
document.removeEventListener('click', handleDocumentClick)
})
</script>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+36 -28
View File
@@ -44,17 +44,10 @@
:key="date"
class="relative"
>
<!-- Vertical line from date to last icon -->
<div
v-if="group.length > 0"
class="absolute left-3 w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
:style="getTimelineHeight(group)"
></div>
<!-- Date Header -->
<div class="flex items-center gap-3 mb-2 relative">
<div class="relative z-10 bg-white dark:bg-gray-800 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-600">
<time class="text-xs font-medium text-gray-600 dark:text-gray-300">
<time class="text-sm font-medium text-gray-600 dark:text-gray-300">
{{ formatDate(date) }}
</time>
</div>
@@ -69,38 +62,60 @@
class="relative"
>
<!-- Timeline Icon -->
<div
<div
:class="[
'absolute -left-[26px] top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
'absolute -left-[26px] w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',
index === group.length - 1 ? 'top-3' : 'top-1/2 -translate-y-1/2',
getTypeClasses(announcement.type).border
]"
>
<component
:is="getTypeIcon(announcement.type)"
<component
:is="getTypeIcon(announcement.type)"
:class="['w-3 h-3', getTypeClasses(announcement.type).iconColor]"
/>
</div>
<!-- Vertical line segment connecting upward from first icon to date -->
<div
v-if="index === 0"
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
style="left: -16px; top: -2.5rem; height: calc(50% + 2.5rem);"
></div>
<!-- Vertical line segment connecting downward to next icon -->
<div
v-if="index < group.length - 1"
class="absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none"
:style="{
left: '-16px',
top: '50%',
height: index === group.length - 2 ? 'calc(50% + 1.25rem)' : 'calc(50% + 2rem)'
}"
></div>
<!-- Announcement Card -->
<div
<div
:class="[
'rounded-md border p-3 transition-all duration-200 hover:shadow-sm',
getTypeClasses(announcement.type).background
]"
>
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="text-sm leading-relaxed text-gray-900 dark:text-gray-100">{{ announcement.message }}</p>
</div>
<time
<div class="flex items-center gap-3">
<time
:class="[
'text-xs font-mono whitespace-nowrap',
'text-sm font-mono whitespace-nowrap flex-shrink-0',
getTypeClasses(announcement.type).text
]"
:title="formatFullTimestamp(announcement.timestamp)"
>
{{ formatTime(announcement.timestamp) }}
</time>
<div class="flex-1 min-w-0">
<p
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
v-html="formatAnnouncementMessage(announcement.message)"
></p>
</div>
</div>
</div>
</div>
@@ -116,6 +131,7 @@
<script setup>
import { computed, ref } from 'vue'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
import { formatAnnouncementMessage } from '@/utils/markdown'
// Props
const props = defineProps({
@@ -220,14 +236,6 @@ const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none
}
const getTimelineHeight = (group) => {
const height = group.length === 1 ? '2rem' : `${2 + (group.length - 1) * 3.5}rem`
return {
top: '1.5rem',
height
}
}
const formatDate = (dateString) => {
const date = new Date(dateString)
const today = new Date()
@@ -291,4 +299,4 @@ const formatFullTimestamp = (timestamp) => {
margin-left: 1.5rem;
}
}
</style>
</style>
+48 -5
View File
@@ -15,7 +15,7 @@
{{ endpoint.name }}
</span>
</CardTitle>
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground min-h-[1.25rem]">
<span v-if="endpoint.group" class="truncate" :title="endpoint.group">{{ endpoint.group }}</span>
<span v-if="endpoint.group && hostname"></span>
<span v-if="hostname" class="truncate" :title="hostname">{{ hostname }}</span>
@@ -39,10 +39,16 @@
:key="index"
:class="[
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
result ? 'cursor-pointer' : '',
result ? (
result.success
? (selectedResultIndex === index ? 'bg-green-700' : 'bg-green-500 hover:bg-green-700')
: (selectedResultIndex === index ? 'bg-red-700' : 'bg-red-500 hover:bg-red-700')
) : 'bg-gray-200 dark:bg-gray-700'
]"
@mouseenter="result && emit('showTooltip', result, $event)"
@mouseleave="result && emit('showTooltip', null, $event)"
@mouseenter="result && handleMouseEnter(result, $event)"
@mouseleave="result && handleMouseLeave(result, $event)"
@click.stop="result && handleClick(result, $event, index)"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
@@ -57,7 +63,7 @@
<script setup>
/* eslint-disable no-undef */
import { computed } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
@@ -82,6 +88,9 @@ const props = defineProps({
const emit = defineEmits(['showTooltip'])
// Track selected data point
const selectedResultIndex = ref(null)
const latestResult = computed(() => {
if (!props.endpoint.results || props.endpoint.results.length === 0) {
return null
@@ -156,4 +165,38 @@ const newestResultTime = computed(() => {
const navigateToDetails = () => {
router.push(`/endpoints/${props.endpoint.key}`)
}
const handleMouseEnter = (result, event) => {
emit('showTooltip', result, event, 'hover')
}
const handleMouseLeave = (result, event) => {
emit('showTooltip', null, event, 'hover')
}
const handleClick = (result, event, index) => {
// Clear selections in other cards first
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
// Then toggle this card's selection
if (selectedResultIndex.value === index) {
selectedResultIndex.value = null
emit('showTooltip', null, event, 'click')
} else {
selectedResultIndex.value = index
emit('showTooltip', result, event, 'click')
}
}
// Listen for clear selection event
const handleClearSelection = () => {
selectedResultIndex.value = null
}
onMounted(() => {
window.addEventListener('clear-data-point-selection', handleClearSelection)
})
onUnmounted(() => {
window.removeEventListener('clear-data-point-selection', handleClearSelection)
})
</script>
@@ -0,0 +1,212 @@
<template>
<div v-if="announcements && announcements.length" class="past-announcements">
<h2 class="text-2xl font-semibold text-foreground mb-6">Past Announcements</h2>
<div class="space-y-8">
<div
v-for="(group, date) in displayedAnnouncements"
:key="date"
>
<!-- Date Header -->
<div class="mb-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
{{ formatDate(date) }}
</h3>
</div>
<!-- Announcements for this date or empty state -->
<div v-if="group.length > 0" class="space-y-3">
<div
v-for="(announcement, index) in group"
:key="`${date}-${index}-${announcement.timestamp}`"
:class="[
'border-l-4 p-4 transition-all duration-200',
getTypeClasses(announcement.type).background,
getTypeClasses(announcement.type).borderColor
]"
>
<div class="flex items-start gap-3">
<component
:is="getTypeIcon(announcement.type)"
:class="['w-5 h-5 flex-shrink-0 mt-0.5', getTypeClasses(announcement.type).iconColor]"
/>
<time
:class="[
'text-sm font-mono whitespace-nowrap flex-shrink-0 mt-0.5',
getTypeClasses(announcement.type).text
]"
:title="formatFullTimestamp(announcement.timestamp)"
>
{{ formatTime(announcement.timestamp) }}
</time>
<div class="flex-1 min-w-0">
<p
class="text-sm leading-relaxed text-gray-900 dark:text-gray-100"
v-html="formatAnnouncementMessage(announcement.message)"
></p>
</div>
</div>
</div>
</div>
<!-- Empty state for dates without announcements -->
<div v-else class="py-2">
<p class="text-sm italic text-muted-foreground/60">
No incidents reported on this day
</p>
</div>
</div>
<!-- View Older Announcements Link -->
<div v-if="hasOlderAnnouncements && !showAllAnnouncements">
<button @click="showAllAnnouncements = true" class="inline-flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer group">
<ChevronDown class="w-4 h-4 group-hover:translate-y-0.5 transition-transform duration-200" />
<span class="group-hover:underline">View older announcements</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'
import { formatAnnouncementMessage } from '@/utils/markdown'
// Props
const props = defineProps({
announcements: {
type: Array,
default: () => []
}
})
// State
const showAllAnnouncements = ref(false)
// Type configurations (consistent with AnnouncementBanner)
const typeConfigs = {
outage: {
icon: XCircle,
background: 'bg-red-50 dark:bg-red-900/20',
borderColor: 'border-red-500 dark:border-red-400',
iconColor: 'text-red-600 dark:text-red-400',
text: 'text-red-700 dark:text-red-300'
},
warning: {
icon: AlertTriangle,
background: 'bg-yellow-50 dark:bg-yellow-900/20',
borderColor: 'border-yellow-500 dark:border-yellow-400',
iconColor: 'text-yellow-600 dark:text-yellow-400',
text: 'text-yellow-700 dark:text-yellow-300'
},
information: {
icon: Info,
background: 'bg-blue-50 dark:bg-blue-900/20',
borderColor: 'border-blue-500 dark:border-blue-400',
iconColor: 'text-blue-600 dark:text-blue-400',
text: 'text-blue-700 dark:text-blue-300'
},
operational: {
icon: CheckCircle,
background: 'bg-green-50 dark:bg-green-900/20',
borderColor: 'border-green-500 dark:border-green-400',
iconColor: 'text-green-600 dark:text-green-400',
text: 'text-green-700 dark:text-green-300'
},
none: {
icon: Circle,
background: 'bg-gray-50 dark:bg-gray-800/20',
borderColor: 'border-gray-500 dark:border-gray-400',
iconColor: 'text-gray-600 dark:text-gray-400',
text: 'text-gray-700 dark:text-gray-300'
}
}
// Helper to normalize date to start of day
const normalizeDate = (date) => {
const normalized = new Date(date)
normalized.setHours(0, 0, 0, 0)
return normalized
}
// Computed properties
const displayedAnnouncements = computed(() => {
if (!props.announcements?.length) return {}
// Group announcements by date and find oldest
const grouped = {}
let oldest = new Date()
props.announcements.forEach(announcement => {
const date = new Date(announcement.timestamp)
const key = date.toDateString()
grouped[key] = grouped[key] || []
grouped[key].push(announcement)
if (date < oldest) oldest = date
})
// Calculate date range
const today = normalizeDate(new Date())
const endDate = showAllAnnouncements.value
? normalizeDate(oldest)
: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000)
// Build result: today (if has announcements) + yesterday backwards
const result = {}
const todayKey = today.toDateString()
if (grouped[todayKey]) result[todayKey] = grouped[todayKey]
for (let date = new Date(today.getTime() - 24 * 60 * 60 * 1000); date >= endDate; date.setDate(date.getDate() - 1)) {
result[date.toDateString()] = grouped[date.toDateString()] || []
}
return result
})
// Check if there are announcements older than 14 days
const hasOlderAnnouncements = computed(() => {
if (!props.announcements?.length) return false
const fourteenDaysAgo = new Date(normalizeDate(new Date()).getTime() - 14 * 24 * 60 * 60 * 1000)
return props.announcements.some(a => new Date(a.timestamp) < fourteenDaysAgo)
})
// Helper functions
const getTypeIcon = (type) => {
return typeConfigs[type]?.icon || Circle
}
const getTypeClasses = (type) => {
return typeConfigs[type] || typeConfigs.none
}
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
const formatFullTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
})
}
</script>
@@ -0,0 +1,294 @@
<template>
<div class="relative w-full" style="height: 300px;">
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background/50">
<Loading />
</div>
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center text-muted-foreground">
{{ error }}
</div>
<Line v-else :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale } from 'chart.js'
import annotationPlugin from 'chartjs-plugin-annotation'
import 'chartjs-adapter-date-fns'
import { generatePrettyTimeDifference } from '@/utils/time'
import Loading from './Loading.vue'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale, annotationPlugin)
const props = defineProps({
endpointKey: {
type: String,
required: true
},
duration: {
type: String,
required: true,
validator: (value) => ['24h', '7d', '30d'].includes(value)
},
serverUrl: {
type: String,
default: '..'
},
events: {
type: Array,
default: () => []
}
})
const loading = ref(true)
const error = ref(null)
const timestamps = ref([])
const values = ref([])
const isDark = ref(document.documentElement.classList.contains('dark'))
const hoveredEventIndex = ref(null)
// Helper function to get color for unhealthy events
const getEventColor = () => {
// Only UNHEALTHY events are displayed on the chart
return 'rgba(239, 68, 68, 0.8)' // Red
}
// Filter events based on selected duration and calculate durations
const filteredEvents = computed(() => {
if (!props.events || props.events.length === 0) {
return []
}
const now = new Date()
let fromTime
switch (props.duration) {
case '24h':
fromTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case '7d':
fromTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case '30d':
fromTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
default:
return []
}
// Only include UNHEALTHY events and calculate their duration
const unhealthyEvents = []
for (let i = 0; i < props.events.length; i++) {
const event = props.events[i]
if (event.type !== 'UNHEALTHY') continue
const eventTime = new Date(event.timestamp)
if (eventTime < fromTime || eventTime > now) continue
// Find the next event to calculate duration
let duration = null
let isOngoing = false
if (i + 1 < props.events.length) {
const nextEvent = props.events[i + 1]
duration = generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)
} else {
// Still ongoing - calculate duration from event time to now
duration = generatePrettyTimeDifference(now, event.timestamp)
isOngoing = true
}
unhealthyEvents.push({
...event,
duration,
isOngoing
})
}
return unhealthyEvents
})
const chartData = computed(() => {
if (timestamps.value.length === 0) {
return {
labels: [],
datasets: []
}
}
const labels = timestamps.value.map(ts => new Date(ts))
return {
labels,
datasets: [{
label: 'Response Time (ms)',
data: values.value,
borderColor: isDark.value ? 'rgb(96, 165, 250)' : 'rgb(59, 130, 246)',
backgroundColor: isDark.value ? 'rgba(96, 165, 250, 0.1)' : 'rgba(59, 130, 246, 0.1)',
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 4,
tension: 0.1,
fill: true
}]
}
})
const chartOptions = computed(() => {
// Include hoveredEventIndex in dependency tracking
// eslint-disable-next-line no-unused-vars
const _ = hoveredEventIndex.value
// Calculate max Y value for positioning annotations
const maxY = values.value.length > 0 ? Math.max(...values.value) : 0
const midY = maxY / 2
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark.value ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
titleColor: isDark.value ? '#f9fafb' : '#111827',
bodyColor: isDark.value ? '#d1d5db' : '#374151',
borderColor: isDark.value ? '#4b5563' : '#e5e7eb',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
title: (tooltipItems) => {
if (tooltipItems.length > 0) {
const date = new Date(tooltipItems[0].parsed.x)
return date.toLocaleString()
}
return ''
},
label: (context) => {
const value = context.parsed.y
return `${value}ms`
}
}
},
annotation: {
annotations: filteredEvents.value.reduce((acc, event, index) => {
// Find closest data point to determine annotation position
const eventTimestamp = new Date(event.timestamp).getTime()
let closestValue = 0
if (timestamps.value.length > 0 && values.value.length > 0) {
const closestIndex = timestamps.value.reduce((closest, ts, idx) => {
const tsTime = new Date(ts).getTime()
const currentDistance = Math.abs(tsTime - eventTimestamp)
const closestDistance = Math.abs(new Date(timestamps.value[closest]).getTime() - eventTimestamp)
return currentDistance < closestDistance ? idx : closest
}, 0)
closestValue = values.value[closestIndex]
}
// Position annotation at bottom if data point is in lower half, at top if in upper half
const position = closestValue <= midY ? 'end' : 'start'
acc[`event-${index}`] = {
type: 'line',
xMin: new Date(event.timestamp),
xMax: new Date(event.timestamp),
borderColor: getEventColor(),
borderWidth: 1,
borderDash: [5, 5],
enter() {
hoveredEventIndex.value = index
},
leave() {
hoveredEventIndex.value = null
},
label: {
display: () => hoveredEventIndex.value === index,
content: [event.isOngoing ? `Status: ONGOING` : `Status: RESOLVED`, `Unhealthy for ${event.duration}`, `Started at ${new Date(event.timestamp).toLocaleString()}`],
backgroundColor: getEventColor(),
color: '#ffffff',
font: {
size: 11
},
padding: 6,
position
}
}
return acc
}, {})
}
},
scales: {
x: {
type: 'time',
time: {
unit: props.duration === '24h' ? 'hour' : props.duration === '7d' ? 'day' : 'day',
displayFormats: {
hour: 'MMM d, ha',
day: 'MMM d'
}
},
grid: {
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
drawBorder: false
},
ticks: {
color: isDark.value ? '#9ca3af' : '#6b7280',
maxRotation: 0,
autoSkipPadding: 20
}
},
y: {
beginAtZero: true,
grid: {
color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
drawBorder: false
},
ticks: {
color: isDark.value ? '#9ca3af' : '#6b7280',
callback: (value) => `${value}ms`
}
}
}
}
})
const fetchData = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(`${props.serverUrl}/api/v1/endpoints/${props.endpointKey}/response-times/${props.duration}/history`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
timestamps.value = data.timestamps || []
values.value = data.values || []
} else {
error.value = 'Failed to load chart data'
console.error('[ResponseTimeChart] Error:', await response.text())
}
} catch (err) {
error.value = 'Failed to load chart data'
console.error('[ResponseTimeChart] Error:', err)
} finally {
loading.value = false
}
}
watch(() => props.duration, () => {
fetchData()
})
onMounted(() => {
fetchData()
const observer = new MutationObserver(() => {
isDark.value = document.documentElement.classList.contains('dark')
})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
onUnmounted(() => observer.disconnect())
})
</script>
+44 -9
View File
@@ -39,15 +39,21 @@
:key="index"
:class="[
'flex-1 h-6 sm:h-8 rounded-sm transition-all',
result ? (result.success ? 'bg-green-500 hover:bg-green-700' : 'bg-red-500 hover:bg-red-700') : 'bg-gray-200 dark:bg-gray-700'
result ? 'cursor-pointer' : '',
result ? (
result.success
? (selectedResultIndex === index ? 'bg-green-700' : 'bg-green-500 hover:bg-green-700')
: (selectedResultIndex === index ? 'bg-red-700' : 'bg-red-500 hover:bg-red-700')
) : 'bg-gray-200 dark:bg-gray-700'
]"
@mouseenter="result && showTooltip(result, $event)"
@mouseleave="hideTooltip($event)"
@mouseenter="result && handleMouseEnter(result, $event)"
@mouseleave="result && handleMouseLeave(result, $event)"
@click.stop="result && handleClick(result, $event, index)"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground mt-1">
<span>{{ newestResultTime }}</span>
<span>{{ oldestResultTime }}</span>
<span>{{ newestResultTime }}</span>
</div>
</div>
</div>
@@ -56,7 +62,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import StatusBadge from '@/components/StatusBadge.vue'
@@ -77,6 +83,9 @@ const props = defineProps({
const emit = defineEmits(['showTooltip'])
// Track selected data point
const selectedResultIndex = ref(null)
// Computed properties
const displayResults = computed(() => {
const results = [...(props.suite.results || [])]
@@ -143,13 +152,39 @@ const navigateToDetails = () => {
router.push(`/suites/${props.suite.key}`)
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
const handleMouseEnter = (result, event) => {
emit('showTooltip', result, event, 'hover')
}
const hideTooltip = (event) => {
emit('showTooltip', null, event)
const handleMouseLeave = (result, event) => {
emit('showTooltip', null, event, 'hover')
}
const handleClick = (result, event, index) => {
// Clear selections in other cards first
window.dispatchEvent(new CustomEvent('clear-data-point-selection'))
// Then toggle this card's selection
if (selectedResultIndex.value === index) {
selectedResultIndex.value = null
emit('showTooltip', null, event, 'click')
} else {
selectedResultIndex.value = index
emit('showTooltip', result, event, 'click')
}
}
// Listen for clear selection event
const handleClearSelection = () => {
selectedResultIndex.value = null
}
onMounted(() => {
window.addEventListener('clear-data-point-selection', handleClearSelection)
})
onUnmounted(() => {
window.removeEventListener('clear-data-point-selection', handleClearSelection)
})
</script>
<style scoped>
+119 -59
View File
@@ -1,12 +1,12 @@
<template>
<div
id="tooltip"
ref="tooltip"
<div
id="tooltip"
ref="tooltip"
:class="[
'fixed z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
'absolute z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',
'bg-popover text-popover-foreground border-border',
hidden ? 'invisible opacity-0' : 'visible opacity-100'
]"
]"
:style="`top: ${top}px; left: ${left}px;`"
>
<div v-if="result" class="space-y-2">
@@ -96,9 +96,12 @@
<script setup>
/* eslint-disable no-undef */
import { ref, watch, nextTick, computed } from 'vue'
import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { prettifyTimestamp } from '@/utils/time'
const route = useRoute()
const props = defineProps({
event: {
type: [Event, Object],
@@ -107,6 +110,10 @@ const props = defineProps({
result: {
type: Object,
default: null
},
isPersistent: {
type: Boolean,
default: false
}
})
@@ -115,6 +122,7 @@ const hidden = ref(true)
const top = ref(0)
const left = ref(0)
const tooltip = ref(null)
const targetElement = ref(null)
// Computed properties
const isSuiteResult = computed(() => {
@@ -133,75 +141,109 @@ const successCount = computed(() => {
// Methods are imported from utils/time
// Update tooltip position based on target element's current position
const updatePosition = async () => {
if (!targetElement.value || !tooltip.value || hidden.value) return
await nextTick()
const targetRect = targetElement.value.getBoundingClientRect()
const tooltipRect = tooltip.value.getBoundingClientRect()
// For absolute positioning, we need to add scroll offsets
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft
// Default position: below the target (viewport coords + scroll offset)
let newTop = targetRect.bottom + scrollTop + 8
let newLeft = targetRect.left + scrollLeft
// Check if tooltip would overflow the viewport bottom
const spaceBelow = window.innerHeight - targetRect.bottom
const spaceAbove = targetRect.top
if (spaceBelow < tooltipRect.height + 20) {
// Not enough space below, try above
if (spaceAbove > tooltipRect.height + 20) {
// Position above
newTop = targetRect.top + scrollTop - tooltipRect.height - 8
} else {
// Not enough space above either, position at the best spot
if (spaceAbove > spaceBelow) {
// More space above
newTop = scrollTop + 10
} else {
// More space below or equal, keep below but adjust
newTop = scrollTop + window.innerHeight - tooltipRect.height - 10
}
}
}
// Adjust horizontal position if tooltip would overflow right edge
const spaceRight = window.innerWidth - targetRect.left
if (spaceRight < tooltipRect.width + 20) {
// Align right edge of tooltip with right edge of target
newLeft = targetRect.right + scrollLeft - tooltipRect.width
// Make sure it doesn't go off the left edge
if (newLeft < scrollLeft + 10) {
newLeft = scrollLeft + 10
}
}
top.value = Math.round(newTop)
left.value = Math.round(newLeft)
}
const reposition = async () => {
if (!props.event || !props.event.type) return
await nextTick()
if (props.event.type === 'mouseenter' && tooltip.value) {
if ((props.event.type === 'mouseenter' || props.event.type === 'click') && tooltip.value) {
const target = props.event.target
const targetRect = target.getBoundingClientRect()
// First, position tooltip to get its dimensions
// Store the target element for scroll updates
targetElement.value = target
// First, make tooltip visible to get its dimensions
hidden.value = false
await nextTick()
const tooltipRect = tooltip.value.getBoundingClientRect()
// Since tooltip uses position: fixed, we work with viewport coordinates
// getBoundingClientRect() already gives us viewport-relative positions
// Default position: below the target
let newTop = targetRect.bottom + 8
let newLeft = targetRect.left
// Check if tooltip would overflow the viewport bottom
const spaceBelow = window.innerHeight - targetRect.bottom
const spaceAbove = targetRect.top
if (spaceBelow < tooltipRect.height + 20) {
// Not enough space below, try above
if (spaceAbove > tooltipRect.height + 20) {
// Position above
newTop = targetRect.top - tooltipRect.height - 8
} else {
// Not enough space above either, position at the best spot
if (spaceAbove > spaceBelow) {
// More space above
newTop = 10
} else {
// More space below or equal, keep below but adjust
newTop = window.innerHeight - tooltipRect.height - 10
}
}
}
// Adjust horizontal position if tooltip would overflow right edge
const spaceRight = window.innerWidth - targetRect.left
if (spaceRight < tooltipRect.width + 20) {
// Align right edge of tooltip with right edge of target
newLeft = targetRect.right - tooltipRect.width
// Make sure it doesn't go off the left edge
if (newLeft < 10) {
newLeft = 10
}
}
top.value = Math.round(newTop)
left.value = Math.round(newLeft)
// Update position
await updatePosition()
} else if (props.event.type === 'mouseleave') {
hidden.value = true
// Only hide on mouseleave if not in persistent mode
if (!props.isPersistent) {
hidden.value = true
targetElement.value = null
}
}
}
// Handle resize events (still needed for viewport size changes)
const handleResize = () => {
updatePosition()
}
// Lifecycle hooks
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// Watchers
watch(() => props.event, (newEvent) => {
if (newEvent && newEvent.type) {
if (newEvent.type === 'mouseenter') {
if (newEvent.type === 'mouseenter' || newEvent.type === 'click') {
hidden.value = false
nextTick(() => reposition())
} else if (newEvent.type === 'mouseleave') {
hidden.value = true
// Only hide on mouseleave if not in persistent mode
if (!props.isPersistent) {
hidden.value = true
}
}
}
}, { immediate: true })
@@ -211,4 +253,22 @@ watch(() => props.result, () => {
nextTick(() => reposition())
}
})
// Watch for persistent state changes and result changes
watch(() => [props.isPersistent, props.result], ([isPersistent, result]) => {
if (!isPersistent && !result) {
// Hide tooltip when both persistent mode is off and no result
hidden.value = true
} else if (result && (isPersistent || props.event?.type === 'mouseenter')) {
// Show tooltip when there's a result and either persistent or hovering
hidden.value = false
nextTick(() => reposition())
}
})
// Watch for route changes and hide tooltip
watch(() => route.path, () => {
hidden.value = true
targetElement.value = null
})
</script>
+1 -1
View File
@@ -7,7 +7,7 @@
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
defineProps({
variant: {
+1 -1
View File
@@ -10,7 +10,7 @@
<script setup>
/* eslint-disable no-undef */
import { cva } from 'class-variance-authority'
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
defineProps({
variant: {
+1 -1
View File
@@ -5,5 +5,5 @@
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
</script>
@@ -5,5 +5,5 @@
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
</script>
@@ -5,5 +5,5 @@
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
</script>
+1 -1
View File
@@ -5,5 +5,5 @@
</template>
<script setup>
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
</script>
+1 -1
View File
@@ -11,7 +11,7 @@
<script setup>
/* eslint-disable no-undef */
import { combineClasses } from '@/lib/utils'
import { combineClasses } from '@/utils/misc'
defineProps({
modelValue: {
+46
View File
@@ -0,0 +1,46 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const escapeHtml = (value) => {
if (value === null || value === undefined) {
return ''
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
const renderer = new marked.Renderer()
renderer.link = (tokenOrHref, title, text) => {
const tokenObject = typeof tokenOrHref === 'object' && tokenOrHref !== null
? tokenOrHref
: null
const href = tokenObject ? tokenObject.href : tokenOrHref
const resolvedTitle = tokenObject ? tokenObject.title : title
const resolvedText = tokenObject ? tokenObject.text : text
const url = escapeHtml(href || '')
const titleAttribute = resolvedTitle ? ` title="${escapeHtml(resolvedTitle)}"` : ''
const linkText = resolvedText || ''
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="text-blue-700 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline font-medium"${titleAttribute}>${linkText}</a>`
}
marked.use({
renderer,
breaks: true,
gfm: true,
headerIds: false,
mangle: false
})
export const formatAnnouncementMessage = (message) => {
if (!message) {
return ''
}
const markdown = String(message)
const html = marked.parse(markdown)
return DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] })
}
+22 -2
View File
@@ -31,8 +31,28 @@ export const generatePrettyTimeAgo = (timestamp) => {
* @returns {string} Time difference string
*/
export const generatePrettyTimeDifference = (start, end) => {
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
return minutes + (minutes === 1 ? ' minute' : ' minutes');
const ms = new Date(start) - new Date(end)
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
const hoursText = hours + (hours === 1 ? ' hour' : ' hours')
if (remainingMinutes > 0) {
return hoursText + ' ' + remainingMinutes + (remainingMinutes === 1 ? ' minute' : ' minutes')
}
return hoursText
} else if (minutes > 0) {
const remainingSeconds = seconds % 60
const minutesText = minutes + (minutes === 1 ? ' minute' : ' minutes')
if (remainingSeconds > 0) {
return minutesText + ' ' + remainingSeconds + (remainingSeconds === 1 ? ' second' : ' seconds')
}
return minutesText
} else {
return seconds + (seconds === 1 ? ' second' : ' seconds')
}
}
/**
+10 -7
View File
@@ -117,7 +117,13 @@
</div>
</CardHeader>
<CardContent>
<img :src="generateResponseTimeChartImageURL(selectedChartDuration)" alt="Response time chart" class="w-full" />
<ResponseTimeChart
v-if="endpointStatus && endpointStatus.key"
:endpointKey="endpointStatus.key"
:duration="selectedChartDuration"
:serverUrl="serverUrl"
:events="endpointStatus.events || []"
/>
</CardContent>
</Card>
@@ -206,6 +212,7 @@ import EndpointCard from '@/components/EndpointCard.vue'
import Settings from '@/components/Settings.vue'
import Pagination from '@/components/Pagination.vue'
import Loading from '@/components/Loading.vue'
import ResponseTimeChart from '@/components/ResponseTimeChart.vue'
import { SERVER_URL } from '@/main.js'
import { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'
@@ -369,8 +376,8 @@ const changePage = (page) => {
fetchData()
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
const showTooltip = (result, event, action = 'hover') => {
emit('showTooltip', result, event, action)
}
const prettifyTimestamp = (timestamp) => {
@@ -389,10 +396,6 @@ const generateResponseTimeBadgeImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`
}
const generateResponseTimeChartImageURL = (duration) => {
return `${serverUrl}/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/chart.svg`
}
onMounted(() => {
fetchData()
})
+19 -4
View File
@@ -22,8 +22,8 @@
</Button>
</div>
</div>
<!-- Announcement Banner -->
<AnnouncementBanner :announcements="props.announcements" />
<!-- Announcement Banner (Active Announcements) -->
<AnnouncementBanner :announcements="activeAnnouncements" />
<!-- Search bar -->
<SearchBar
@search="handleSearch"
@@ -170,6 +170,11 @@
</Button>
</div>
</div>
<!-- Past Announcements Section -->
<div v-if="archivedAnnouncements.length > 0" class="mt-12 pb-8">
<PastAnnouncements :announcements="archivedAnnouncements" />
</div>
</div>
<Settings @refreshData="fetchData" />
@@ -187,6 +192,7 @@ import SearchBar from '@/components/SearchBar.vue'
import Settings from '@/components/Settings.vue'
import Loading from '@/components/Loading.vue'
import AnnouncementBanner from '@/components/AnnouncementBanner.vue'
import PastAnnouncements from '@/components/PastAnnouncements.vue'
import { SERVER_URL } from '@/main.js'
const props = defineProps({
@@ -196,6 +202,15 @@ const props = defineProps({
}
})
// Computed properties for active and archived announcements
const activeAnnouncements = computed(() => {
return props.announcements ? props.announcements.filter(a => !a.archived) : []
})
const archivedAnnouncements = computed(() => {
return props.announcements ? props.announcements.filter(a => a.archived) : []
})
const emit = defineEmits(['showTooltip'])
const endpointStatuses = ref([])
@@ -471,8 +486,8 @@ const toggleShowAverageResponseTime = () => {
showAverageResponseTime.value = !showAverageResponseTime.value
}
const showTooltip = (result, event) => {
emit('showTooltip', result, event)
const showTooltip = (result, event, action = 'hover') => {
emit('showTooltip', result, event, action)
}
const calculateUnhealthyCount = (endpoints) => {
+21 -9
View File
@@ -14,7 +14,7 @@
<p class="text-muted-foreground mt-2">
<span v-if="suite?.group">{{ suite.group }} </span>
<span v-if="latestResult">
{{ selectedResult && selectedResult !== sortedResults[0] ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}
{{ selectedResult && selectedResult.timestamp !== sortedResults[0]?.timestamp ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}
</span>
</p>
</div>
@@ -41,7 +41,7 @@
<!-- Latest Execution -->
<Card v-if="latestResult">
<CardHeader>
<CardTitle>Latest Execution</CardTitle>
<CardTitle>{{ selectedResult?.timestamp === sortedResults[0]?.timestamp ? 'Latest Execution' : `Execution at ${formatTimestamp(selectedResult.timestamp)}` }}</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-4">
@@ -107,7 +107,7 @@
:key="index"
class="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer"
@click="selectedResult = result"
:class="{ 'bg-accent': selectedResult === result }"
:class="{ 'bg-accent': selectedResult && selectedResult.timestamp === result.timestamp }"
>
<div class="flex items-center gap-3">
<StatusBadge :status="result.success ? 'healthy' : 'unhealthy'" size="sm" />
@@ -184,20 +184,30 @@ const latestResult = computed(() => {
// Methods
const fetchData = async () => {
loading.value = true
// Don't show loading state on refresh to prevent UI flicker
const isInitialLoad = !suite.value
if (isInitialLoad) {
loading.value = true
}
try {
const response = await fetch(`${SERVER_URL}/api/v1/suites/${route.params.key}/statuses`, {
credentials: 'include'
})
if (response.status === 200) {
const data = await response.json()
const oldSuite = suite.value
suite.value = data
if (data.results && data.results.length > 0 && !selectedResult.value) {
if (data.results && data.results.length > 0) {
// Sort results by timestamp to get the most recent one
const sorted = [...data.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
selectedResult.value = sorted[0]
// Update selectedResult if: no result selected, or currently viewing the latest result
const wasViewingLatest = !selectedResult.value ||
(oldSuite?.results && selectedResult.value.timestamp === [...oldSuite.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]?.timestamp)
if (wasViewingLatest) {
selectedResult.value = sorted[0]
}
}
} else if (response.status === 404) {
suite.value = null
@@ -207,7 +217,9 @@ const fetchData = async () => {
} catch (error) {
console.error('[SuiteDetails][fetchData] Error:', error)
} finally {
loading.value = false
if (isInitialLoad) {
loading.value = false
}
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long