Repository: TwiN/gatus
Branch: master
Commit: 3a6ad14cba36
Files: 325
Total size: 2.6 MB
Directory structure:
gitextract_9_t34r5f/
├── .dockerignore
├── .examples/
│ ├── docker-compose/
│ │ └── compose.yaml
│ ├── docker-compose-grafana-prometheus/
│ │ ├── README.md
│ │ ├── compose.yaml
│ │ ├── grafana/
│ │ │ ├── grafana.ini
│ │ │ └── provisioning/
│ │ │ ├── dashboards/
│ │ │ │ ├── dashboard.yml
│ │ │ │ └── gatus.json
│ │ │ └── datasources/
│ │ │ └── prometheus.yml
│ │ └── prometheus/
│ │ └── prometheus.yml
│ ├── docker-compose-mattermost/
│ │ └── compose.yaml
│ ├── docker-compose-mtls/
│ │ ├── certs/
│ │ │ ├── client/
│ │ │ │ ├── client.crt
│ │ │ │ └── client.key
│ │ │ └── server/
│ │ │ ├── ca.crt
│ │ │ ├── server.crt
│ │ │ └── server.key
│ │ ├── compose.yaml
│ │ └── nginx/
│ │ └── default.conf
│ ├── docker-compose-multiple-config-files/
│ │ ├── compose.yaml
│ │ └── config/
│ │ ├── backend.yaml
│ │ ├── frontend.yaml
│ │ └── global.yaml
│ ├── docker-compose-postgres-storage/
│ │ └── compose.yaml
│ ├── docker-compose-sqlite-storage/
│ │ ├── compose.yaml
│ │ └── data/
│ │ └── .gitkeep
│ ├── docker-minimal/
│ │ └── Dockerfile
│ ├── kubernetes/
│ │ └── gatus.yaml
│ └── nixos/
│ ├── README.md
│ └── gatus.nix
├── .gitattributes
├── .github/
│ ├── FUNDING.yml
│ ├── assets/
│ │ └── gatus-diagram.drawio
│ ├── codecov.yml
│ ├── dependabot.yml
│ └── workflows/
│ ├── benchmark.yml
│ ├── labeler.yml
│ ├── publish-custom.yml
│ ├── publish-experimental.yml
│ ├── publish-latest.yml
│ ├── publish-release.yml
│ ├── regenerate-static-assets.yml
│ ├── test-ui.yml
│ └── test.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── alerting/
│ ├── alert/
│ │ ├── alert.go
│ │ ├── alert_test.go
│ │ └── type.go
│ ├── config.go
│ └── provider/
│ ├── awsses/
│ │ ├── awsses.go
│ │ └── awsses_test.go
│ ├── clickup/
│ │ ├── clickup.go
│ │ └── clickup_test.go
│ ├── custom/
│ │ ├── custom.go
│ │ └── custom_test.go
│ ├── datadog/
│ │ ├── datadog.go
│ │ └── datadog_test.go
│ ├── discord/
│ │ ├── discord.go
│ │ └── discord_test.go
│ ├── email/
│ │ ├── email.go
│ │ └── email_test.go
│ ├── gitea/
│ │ ├── gitea.go
│ │ └── gitea_test.go
│ ├── github/
│ │ ├── github.go
│ │ └── github_test.go
│ ├── gitlab/
│ │ ├── gitlab.go
│ │ └── gitlab_test.go
│ ├── googlechat/
│ │ ├── googlechat.go
│ │ └── googlechat_test.go
│ ├── gotify/
│ │ ├── gotify.go
│ │ └── gotify_test.go
│ ├── homeassistant/
│ │ ├── homeassistant.go
│ │ └── homeassistant_test.go
│ ├── ifttt/
│ │ ├── ifttt.go
│ │ └── ifttt_test.go
│ ├── ilert/
│ │ ├── ilert.go
│ │ └── ilert_test.go
│ ├── incidentio/
│ │ ├── dedup.go
│ │ ├── incidentio.go
│ │ └── incidentio_test.go
│ ├── line/
│ │ ├── line.go
│ │ └── line_test.go
│ ├── matrix/
│ │ ├── matrix.go
│ │ └── matrix_test.go
│ ├── mattermost/
│ │ ├── mattermost.go
│ │ └── mattermost_test.go
│ ├── messagebird/
│ │ ├── messagebird.go
│ │ └── messagebird_test.go
│ ├── n8n/
│ │ ├── n8n.go
│ │ └── n8n_test.go
│ ├── newrelic/
│ │ ├── newrelic.go
│ │ └── newrelic_test.go
│ ├── ntfy/
│ │ ├── ntfy.go
│ │ └── ntfy_test.go
│ ├── opsgenie/
│ │ ├── opsgenie.go
│ │ └── opsgenie_test.go
│ ├── pagerduty/
│ │ ├── pagerduty.go
│ │ └── pagerduty_test.go
│ ├── plivo/
│ │ ├── plivo.go
│ │ └── plivo_test.go
│ ├── provider.go
│ ├── provider_test.go
│ ├── pushover/
│ │ ├── pushover.go
│ │ └── pushover_test.go
│ ├── rocketchat/
│ │ ├── rocketchat.go
│ │ └── rocketchat_test.go
│ ├── sendgrid/
│ │ ├── sendgrid.go
│ │ └── sendgrid_test.go
│ ├── signal/
│ │ ├── signal.go
│ │ └── signal_test.go
│ ├── signl4/
│ │ ├── signl4.go
│ │ └── signl4_test.go
│ ├── slack/
│ │ ├── slack.go
│ │ └── slack_test.go
│ ├── splunk/
│ │ ├── splunk.go
│ │ └── splunk_test.go
│ ├── squadcast/
│ │ ├── squadcast.go
│ │ └── squadcast_test.go
│ ├── teams/
│ │ ├── teams.go
│ │ └── teams_test.go
│ ├── teamsworkflows/
│ │ ├── teamsworkflows.go
│ │ └── teamsworkflows_test.go
│ ├── telegram/
│ │ ├── telegram.go
│ │ └── telegram_test.go
│ ├── twilio/
│ │ ├── twilio.go
│ │ └── twilio_test.go
│ ├── vonage/
│ │ ├── vonage.go
│ │ └── vonage_test.go
│ ├── webex/
│ │ ├── webex.go
│ │ └── webex_test.go
│ ├── zapier/
│ │ ├── zapier.go
│ │ └── zapier_test.go
│ └── zulip/
│ ├── zulip.go
│ └── zulip_test.go
├── api/
│ ├── api.go
│ ├── api_test.go
│ ├── badge.go
│ ├── badge_test.go
│ ├── cache.go
│ ├── chart.go
│ ├── chart_test.go
│ ├── config.go
│ ├── config_test.go
│ ├── custom_css.go
│ ├── endpoint_status.go
│ ├── endpoint_status_test.go
│ ├── external_endpoint.go
│ ├── external_endpoint_test.go
│ ├── raw.go
│ ├── raw_test.go
│ ├── spa.go
│ ├── spa_test.go
│ ├── suite_status.go
│ ├── suite_status_test.go
│ ├── util.go
│ └── util_test.go
├── client/
│ ├── client.go
│ ├── client_test.go
│ ├── config.go
│ ├── config_test.go
│ └── grpc.go
├── config/
│ ├── announcement/
│ │ ├── announcement.go
│ │ └── announcement_test.go
│ ├── config.go
│ ├── config_test.go
│ ├── connectivity/
│ │ ├── connectivity.go
│ │ └── connectivity_test.go
│ ├── endpoint/
│ │ ├── common.go
│ │ ├── common_test.go
│ │ ├── condition.go
│ │ ├── condition_bench_test.go
│ │ ├── condition_result.go
│ │ ├── condition_test.go
│ │ ├── dns/
│ │ │ ├── dns.go
│ │ │ └── dns_test.go
│ │ ├── endpoint.go
│ │ ├── endpoint_test.go
│ │ ├── event.go
│ │ ├── event_test.go
│ │ ├── external_endpoint.go
│ │ ├── external_endpoint_test.go
│ │ ├── heartbeat/
│ │ │ └── heartbeat.go
│ │ ├── placeholder.go
│ │ ├── placeholder_test.go
│ │ ├── result.go
│ │ ├── result_test.go
│ │ ├── ssh/
│ │ │ ├── ssh.go
│ │ │ └── ssh_test.go
│ │ ├── status.go
│ │ ├── status_test.go
│ │ ├── ui/
│ │ │ ├── ui.go
│ │ │ └── ui_test.go
│ │ └── uptime.go
│ ├── gontext/
│ │ ├── gontext.go
│ │ └── gontext_test.go
│ ├── key/
│ │ ├── key.go
│ │ ├── key_bench_test.go
│ │ └── key_test.go
│ ├── maintenance/
│ │ ├── maintenance.go
│ │ └── maintenance_test.go
│ ├── remote/
│ │ └── remote.go
│ ├── suite/
│ │ ├── result.go
│ │ ├── suite.go
│ │ ├── suite_status.go
│ │ └── suite_test.go
│ ├── tunneling/
│ │ ├── sshtunnel/
│ │ │ ├── sshtunnel.go
│ │ │ └── sshtunnel_test.go
│ │ ├── tunneling.go
│ │ └── tunneling_test.go
│ ├── ui/
│ │ ├── ui.go
│ │ └── ui_test.go
│ ├── util.go
│ └── web/
│ ├── web.go
│ └── web_test.go
├── controller/
│ ├── controller.go
│ └── controller_test.go
├── docs/
│ └── pagerduty-integration-guide.md
├── go.mod
├── go.sum
├── jsonpath/
│ ├── jsonpath.go
│ ├── jsonpath_bench_test.go
│ └── jsonpath_test.go
├── main.go
├── metrics/
│ ├── metrics.go
│ └── metrics_test.go
├── pattern/
│ ├── pattern.go
│ ├── pattern_bench_test.go
│ └── pattern_test.go
├── security/
│ ├── basic.go
│ ├── basic_test.go
│ ├── config.go
│ ├── config_test.go
│ ├── oidc.go
│ ├── oidc_test.go
│ └── sessions.go
├── storage/
│ ├── config.go
│ ├── store/
│ │ ├── common/
│ │ │ ├── errors.go
│ │ │ └── paging/
│ │ │ ├── endpoint_status_params.go
│ │ │ ├── endpoint_status_params_test.go
│ │ │ ├── suite_status_params.go
│ │ │ └── suite_status_params_test.go
│ │ ├── memory/
│ │ │ ├── memory.go
│ │ │ ├── memory_test.go
│ │ │ ├── uptime.go
│ │ │ ├── uptime_bench_test.go
│ │ │ ├── uptime_test.go
│ │ │ ├── util.go
│ │ │ ├── util_bench_test.go
│ │ │ └── util_test.go
│ │ ├── sql/
│ │ │ ├── specific_postgres.go
│ │ │ ├── specific_sqlite.go
│ │ │ ├── sql.go
│ │ │ └── sql_test.go
│ │ ├── store.go
│ │ ├── store_bench_test.go
│ │ └── store_test.go
│ └── type.go
├── test/
│ └── mock.go
├── testdata/
│ ├── badcert.key
│ ├── badcert.pem
│ ├── cert.key
│ └── cert.pem
├── watchdog/
│ ├── alerting.go
│ ├── alerting_test.go
│ ├── endpoint.go
│ ├── external_endpoint.go
│ ├── suite.go
│ └── watchdog.go
└── web/
├── app/
│ ├── .gitignore
│ ├── README.md
│ ├── babel.config.js
│ ├── package.json
│ ├── postcss.config.js
│ ├── public/
│ │ ├── index.html
│ │ └── manifest.json
│ ├── src/
│ │ ├── App.vue
│ │ ├── components/
│ │ │ ├── AnnouncementBanner.vue
│ │ │ ├── EndpointCard.vue
│ │ │ ├── FlowStep.vue
│ │ │ ├── Loading.vue
│ │ │ ├── Pagination.vue
│ │ │ ├── PastAnnouncements.vue
│ │ │ ├── ResponseTimeChart.vue
│ │ │ ├── SearchBar.vue
│ │ │ ├── SequentialFlowDiagram.vue
│ │ │ ├── Settings.vue
│ │ │ ├── Social.vue
│ │ │ ├── StatusBadge.vue
│ │ │ ├── StepDetailsModal.vue
│ │ │ ├── SuiteCard.vue
│ │ │ ├── Tooltip.vue
│ │ │ └── ui/
│ │ │ ├── badge/
│ │ │ │ ├── Badge.vue
│ │ │ │ └── index.js
│ │ │ ├── button/
│ │ │ │ ├── Button.vue
│ │ │ │ └── index.js
│ │ │ ├── card/
│ │ │ │ ├── Card.vue
│ │ │ │ ├── CardContent.vue
│ │ │ │ ├── CardHeader.vue
│ │ │ │ ├── CardTitle.vue
│ │ │ │ └── index.js
│ │ │ ├── input/
│ │ │ │ ├── Input.vue
│ │ │ │ └── index.js
│ │ │ └── select/
│ │ │ ├── Select.vue
│ │ │ └── index.js
│ │ ├── index.css
│ │ ├── main.js
│ │ ├── router/
│ │ │ └── index.js
│ │ ├── utils/
│ │ │ ├── format.js
│ │ │ ├── markdown.js
│ │ │ ├── misc.js
│ │ │ └── time.js
│ │ └── views/
│ │ ├── EndpointDetails.vue
│ │ ├── Home.vue
│ │ └── SuiteDetails.vue
│ ├── tailwind.config.js
│ └── vue.config.js
├── static/
│ ├── css/
│ │ └── app.css
│ ├── index.html
│ ├── js/
│ │ ├── app.js
│ │ └── chunk-vendors.js
│ └── manifest.json
├── static.go
└── static_test.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
.examples
Dockerfile
.github
.idea
.git
web/app
*.db
testdata
================================================
FILE: .examples/docker-compose/compose.yaml
================================================
services:
gatus:
image: twinproduction/gatus:latest
ports:
- 8080:8080
volumes:
- ./config:/config
================================================
FILE: .examples/docker-compose-grafana-prometheus/README.md
================================================
## Usage
Gatus exposes Prometheus metrics at `/metrics` if the `metrics` configuration option is set to `true`.
To run this example, all you need to do is execute the following command:
```console
docker-compose up
```
Once you've done the above, you should be able to access the Grafana dashboard at `http://localhost:3000`.

## Queries
By default, this example has a Grafana dashboard with some panels, but for the sake of verbosity, you'll find
a list of simple queries below. Those make use of the `key` parameter, which is a concatenation of the endpoint's
group and name.
### Success rate
```
sum(rate(gatus_results_total{success="true"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)
```
### Response time
```
gatus_results_duration_seconds
```
### Total results per minute
```
sum(rate(gatus_results_total[5m])*60) by (key)
```
### Total successful results per minute
```
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
```
### Total unsuccessful results per minute
```
sum(rate(gatus_results_total{success="false"}[5m])*60) by (key)
```
================================================
FILE: .examples/docker-compose-grafana-prometheus/compose.yaml
================================================
services:
gatus:
container_name: gatus
image: twinproduction/gatus
restart: always
ports:
- "8080:8080"
volumes:
- ./config:/config
networks:
- metrics
prometheus:
container_name: prometheus
image: prom/prometheus:v3.5.0
restart: always
command: --config.file=/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- metrics
grafana:
container_name: grafana
image: grafana/grafana:12.1.0
restart: always
environment:
GF_SECURITY_ADMIN_PASSWORD: secret
ports:
- "3000:3000"
volumes:
- ./grafana/grafana.ini/:/etc/grafana/grafana.ini:ro
- ./grafana/provisioning/:/etc/grafana/provisioning/:ro
networks:
- metrics
networks:
metrics:
driver: bridge
================================================
FILE: .examples/docker-compose-grafana-prometheus/grafana/grafana.ini
================================================
[paths]
[server]
[database]
[session]
[dataproxy]
[analytics]
reporting_enabled = false
[security]
[snapshots]
[dashboards]
[users]
allow_sign_up = false
default_theme = light
[auth]
[auth.anonymous]
enabled = true
org_name = Main Org.
org_role = Admin
[auth.github]
[auth.google]
[auth.generic_oauth]
[auth.grafana_com]
[auth.proxy]
[auth.basic]
[auth.ldap]
[smtp]
[emails]
[log]
mode = console
[log.console]
[log.file]
[log.syslog]
[alerting]
[explore]
[metrics]
enabled = true
[metrics.graphite]
[tracing.jaeger]
[grafana_com]
[external_image_storage]
[external_image_storage.s3]
[external_image_storage.webdav]
[external_image_storage.gcs]
[external_image_storage.azure_blob]
[external_image_storage.local]
[rendering]
[enterprise]
================================================
FILE: .examples/docker-compose-grafana-prometheus/grafana/provisioning/dashboards/dashboard.yml
================================================
apiVersion: 1
providers:
- name: 'Prometheus'
orgId: 1
folder: ''
type: file
disableDeletion: false
editable: true
options:
path: /etc/grafana/provisioning/dashboards
================================================
FILE: .examples/docker-compose-grafana-prometheus/grafana/provisioning/dashboards/gatus.json
================================================
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Monitoring dashboard for service uptime monitoring using Gatus metrics",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 41,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Services with certificate expiration warnings",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"noValue": "0",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "yellow",
"value": 30
},
{
"color": "red",
"value": 7
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 0,
"y": 0
},
"id": 8,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "count(gatus_results_certificate_expiration_seconds < 2592000)",
"hide": false,
"legendFormat": "Expiring < 30 days",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "count(gatus_results_certificate_expiration_seconds < 604800)",
"legendFormat": "Expiring < 7 days",
"refId": "B"
}
],
"title": "Certificate Warnings",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Overall service availability",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "yellow",
"value": 95
},
{
"color": "green",
"value": 99
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 4,
"y": 0
},
"id": 9,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "(sum(gatus_results_endpoint_success) / count(gatus_results_endpoint_success)) * 100",
"legendFormat": "Overall Availability",
"refId": "A"
}
],
"title": "Overall Availability",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Number of services being monitored",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 8,
"y": 0
},
"id": 10,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "count(gatus_results_endpoint_success)",
"legendFormat": "Total Services",
"refId": "A"
}
],
"title": "Monitored Services",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Overview of service health status across all groups",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false,
"viz": false
}
},
"mappings": [],
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 1,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"values": []
},
"pieType": "pie",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(gatus_results_endpoint_success) by (group)",
"legendFormat": "{{group}} - UP",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(1 - gatus_results_endpoint_success) by (group)",
"legendFormat": "{{group}} - DOWN",
"refId": "B"
}
],
"title": "Service Health by Group",
"type": "piechart"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Domain expiration times for all domains",
"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": 0,
"y": 8
},
"id": 3,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"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": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "gatus_results_certificate_expiration_seconds",
"format": "table",
"instant": true,
"legendFormat": "__auto",
"refId": "A"
}
],
"title": "SSL Certificate 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": "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",
"uid": "$datasource"
},
"description": "Current status of all monitored services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [
{
"options": {
"0": {
"color": "red",
"index": 0,
"text": "DOWN"
},
"1": {
"color": "green",
"index": 1,
"text": "UP"
}
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Status"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"type": "color-background"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "Response Time"
},
"properties": [
{
"id": "unit",
"value": "s"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 16
},
"id": 2,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": [
{
"desc": false,
"displayName": "Status"
}
]
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "gatus_results_endpoint_success",
"format": "table",
"instant": true,
"legendFormat": "__auto",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "gatus_results_duration_seconds",
"format": "table",
"instant": true,
"legendFormat": "__auto",
"refId": "B"
}
],
"title": "Service Status Overview",
"transformations": [
{
"id": "joinByField",
"options": {
"byField": "name",
"mode": "outer"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"__name__": true,
"app_kubernetes_io_instance": true,
"app_kubernetes_io_managed_by": true,
"app_kubernetes_io_name": true,
"app_kubernetes_io_service": true,
"group 2": true,
"helm_sh_chart": true,
"instance": true,
"job": true,
"key": true,
"type": true
},
"includeByName": {},
"indexByName": {
"Time 1": 4,
"Time 2": 15,
"Value #A": 2,
"Value #B": 3,
"__name__ 1": 5,
"__name__ 2": 16,
"app_kubernetes_io_instance 1": 6,
"app_kubernetes_io_instance 2": 17,
"app_kubernetes_io_managed_by 1": 7,
"app_kubernetes_io_managed_by 2": 18,
"app_kubernetes_io_name 1": 8,
"app_kubernetes_io_name 2": 19,
"app_kubernetes_io_service 1": 9,
"app_kubernetes_io_service 2": 20,
"group 1": 0,
"group 2": 21,
"helm_sh_chart 1": 10,
"helm_sh_chart 2": 22,
"instance 1": 11,
"instance 2": 23,
"job 1": 12,
"job 2": 24,
"key 1": 13,
"key 2": 25,
"name": 1,
"type 1": 14,
"type 2": 26
},
"renameByName": {
"Value #A": "Status",
"Value #B": "Response Time",
"group": "Group",
"group 1": "Group",
"name": "Service"
}
}
}
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Response times for all monitored 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": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 24
},
"id": 4,
"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": "gatus_results_duration_seconds",
"legendFormat": "{{name}} ({{group}})",
"refId": "A"
}
],
"title": "Response Times",
"type": "timeseries"
}
],
"preload": false,
"refresh": "30s",
"schemaVersion": 41,
"tags": [
"gatus",
"monitoring",
"uptime"
],
"templating": {
"list": [
{
"current": {
"text": "prometheus",
"value": "cedv077q7bbwgd"
},
"description": "Select your Prometheus datasource",
"includeAll": false,
"label": "Datasource",
"name": "datasource",
"options": [],
"query": "prometheus",
"refresh": 1,
"regex": "",
"type": "datasource"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Gatus - Service Monitoring Dashboard",
"uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
"version": 1
}
================================================
FILE: .examples/docker-compose-grafana-prometheus/grafana/provisioning/datasources/prometheus.yml
================================================
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
version: 1
editable: false
================================================
FILE: .examples/docker-compose-grafana-prometheus/prometheus/prometheus.yml
================================================
scrape_configs:
- job_name: gatus
scrape_interval: 10s
static_configs:
- targets:
- gatus:8080
================================================
FILE: .examples/docker-compose-mattermost/compose.yaml
================================================
services:
gatus:
container_name: gatus
image: twinproduction/gatus:latest
ports:
- "8080:8080"
volumes:
- ./config:/config
networks:
- default
mattermost:
container_name: mattermost
image: mattermost/mattermost-preview:5.26.0
ports:
- "8065:8065"
networks:
- default
networks:
default:
driver: bridge
================================================
FILE: .examples/docker-compose-mtls/certs/client/client.crt
================================================
-----BEGIN CERTIFICATE-----
MIIFBjCCAu6gAwIBAgIUHJXHAqywj2v25AgX7pDSZ+LX4iAwDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTQ1MDFaFw0yOTA0MjQw
MTQ1MDFaMBExDzANBgNVBAMMBmNsaWVudDCCAiIwDQYJKoZIhvcNAQEBBQADggIP
ADCCAgoCggIBANTmRlS5BNG82mOdrhtRPIBD5U40nEW4CVFm85ZJ4Bge4Ty86juf
aoCnI6AEfwpVnJhXPzjUsMBxJFMbiCB+QTJRpxTphtK7orpbwRHjaDZNaLr1MrUO
ieADGiHw93zVDikD8FP5vG+2XWWA56hY84Ac0TR9GqPjsW0nobMgBNgsRtbYUD0B
T5QOItK180xQRn4jbys5jRnr161S+Sbg6mglz1LBFBCLmZnhZFZ8FAn87gumbnWN
etSnu9kX6iOXBIaB+3nuHOL4xmAan8tAyen6mPfkXrE5ogovjqFFMTUJOKQoJVp3
zzm/0XYANxoItFGtdjGMTl5IgI220/6kfpn6PYN7y1kYn5EI+UbobD/CuAhd94p6
aQwOXU53/l+eNH/XnTsL/32QQ6qdq8sYqevlslk1M39kKNewWYCeRzYlCVscQk14
O3fkyXrtRkz30xrzfjvJQ/VzMi+e5UlemsCuCXTVZ5YyBnuWyY+mI6lZICltZSSX
VinKzpz+t4Jl7glhKiGHaNAkBX2oLddyf280zw4Cx7nDMPs4uOHONYpm90IxEOJe
zgJ9YxPK9aaKv2AoYLbvhYyKrVT+TFqoEsbQk4vK0t0Gc1j5z4dET31CSOuxVnnU
LYwtbILFc0uZrbuOAbEbXtjPpw2OGqWagD0QpkE8TjN0Hd0ibyXyUuz5AgMBAAGj
VTBTMBEGA1UdEQQKMAiCBmNsaWVudDAdBgNVHQ4EFgQUleILTHG5lT2RhSe9H4fV
xUh0bNUwHwYDVR0jBBgwFoAUbh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcN
AQELBQADggIBABq8zjRrDaljl867MXAlmbV7eJkSnaWRFct+N//jCVNnKMYaxyQm
+UG12xYP0U9Zr9vhsqwyTZTQFx/ZFiiz2zfXPtUAppV3AjE67IlKRbec3qmUhj0H
Rv20eNNWXTl1XTX5WDV5887TF+HLZm/4W2ZSBbS3V89cFhBLosy7HnBGrP0hACne
ZbdQWnnLHJMDKXkZey1H1ZLQQCQdAKGS147firj29M8uzSRHgrR6pvsNQnRT0zDL
TlTJoxyGTMaoj+1IZvRsAYMZCRb8Yct/v2i/ukIykFWUJZ+1Z3UZhGrX+gdhLfZM
jAP4VQ+vFgwD6NEXAA2DatoRqxbN1ZGJQkvnobWJdZDiYu4hBCs8ugKUTE+0iXWt
hSyrAVUspFCIeDN4xsXT5b0j2Ps4bpSAiGx+aDDTPUnd881I6JGCiIavgvdFMLCW
yOXJOZvXcNQwsndkob5fZAEqetjrARsHhQuygEq/LnPc6lWsO8O6UzYArEiKWTMx
N/5hx12Pb7aaQd1f4P3gmmHMb/YiCQK1Qy5d4v68POeqyrLvAHbvCwEMhBAbnLvw
gne3psql8s5wxhnzwYltcBUmmAw1t33CwzRBGEKifRdLGtA9pbua4G/tomcDDjVS
ChsHGebJvNxOnsQqoGgozqM2x8ScxmJzIflGxrKmEA8ybHpU0d02Xp3b
-----END CERTIFICATE-----
================================================
FILE: .examples/docker-compose-mtls/certs/client/client.key
================================================
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEA1OZGVLkE0bzaY52uG1E8gEPlTjScRbgJUWbzlkngGB7hPLzq
O59qgKcjoAR/ClWcmFc/ONSwwHEkUxuIIH5BMlGnFOmG0ruiulvBEeNoNk1ouvUy
tQ6J4AMaIfD3fNUOKQPwU/m8b7ZdZYDnqFjzgBzRNH0ao+OxbSehsyAE2CxG1thQ
PQFPlA4i0rXzTFBGfiNvKzmNGevXrVL5JuDqaCXPUsEUEIuZmeFkVnwUCfzuC6Zu
dY161Ke72RfqI5cEhoH7ee4c4vjGYBqfy0DJ6fqY9+ResTmiCi+OoUUxNQk4pCgl
WnfPOb/RdgA3Ggi0Ua12MYxOXkiAjbbT/qR+mfo9g3vLWRifkQj5RuhsP8K4CF33
inppDA5dTnf+X540f9edOwv/fZBDqp2ryxip6+WyWTUzf2Qo17BZgJ5HNiUJWxxC
TXg7d+TJeu1GTPfTGvN+O8lD9XMyL57lSV6awK4JdNVnljIGe5bJj6YjqVkgKW1l
JJdWKcrOnP63gmXuCWEqIYdo0CQFfagt13J/bzTPDgLHucMw+zi44c41imb3QjEQ
4l7OAn1jE8r1poq/YChgtu+FjIqtVP5MWqgSxtCTi8rS3QZzWPnPh0RPfUJI67FW
edQtjC1sgsVzS5mtu44BsRte2M+nDY4apZqAPRCmQTxOM3Qd3SJvJfJS7PkCAwEA
AQKCAgAPwAALUStib3aMkLlfpfve1VGyc8FChcySrBYbKS3zOt2Y27T3DOJuesRE
7fA5Yyn+5H1129jo87XR5s3ZnDLV4SUw2THd3H8RCwFWgcdPinHUBZhnEpial5V9
q1DzzY3gSj1OSRcVVfLE3pYaEIflvhFasQ1L0JLAq4I9OSzX5+FPEEOnWmB5Ey6k
/fbuJLDXsLwPAOadDfiFBwgNm0KxdRKdtvugBGPW9s4Fzo9rnxLmjmfKOdmQv96Y
FI/Vat0Cgmfd661RZpbDvKnTpIsLdzw3zTpAIYOzqImvCT+3AmP2qPhSdV3sPMeR
047qqyLZOVxEFXLQFiGvL4uxYUPy8k0ZI9xkgOfZ/uASozMWsHkaD04+UDi1+kw5
nfasZLvOWBW/WE/E1Rfz8IiYTeZbgTnY4CraiLrIRc0LGgD1Df4gNr25+P+LKLyK
/WW89dl6/397HOFnA7CHi7DaA8+9uZAjOWhoCNDdqAVa3QpDD/3/iRiih26bjJfH
2+sarxU8GovDZFxWd59BUP3jkukCFH+CliQy72JtLXiuPNPAWeGV9UXxtIu40sRX
Sax/TQytYi2J9NJFZFMTwVueIfzsWc8dyM+IPAYJQxN94xYKQU4+Rb/wqqHgUfjT
1ZQJb8Cmg56IDY/0EPJWQ0qgnE7TZbY2BOEYbpOzdccwUbcEjQKCAQEA8kVyw4Hw
nqcDWXjzMhOOoRoF8CNwXBvE2KBzpuAioivGcSkjkm8vLGfQYAbDOVMPFt3xlZS0
0lQm894176Kk8BiMqtyPRWWOsv4vYMBTqbehKn09Kbh6lM7d7jO7sh5iWf4jt3Bw
Sk4XhZ9oQ/kpnEKiHPymHQY3pVYEyFCGJ8mdS6g/TWiYmjMjkQDVFA4xkiyJ0S5J
NGYxI+YXtHVTVNSePKvY0h51EqTxsexAphGjXnQ3xoe6e3tVGBkeEkcZlESFD/91
0iqdc5VtKQOwy6Tj4Awk7oK5/u3tfpyIyo31LQIqreTqMO534838lpyp3CbRdvCF
QdCNpKFX1gZgmwKCAQEA4Pa9VKO3Aw95fpp0T81xNi+Js/NhdsvQyv9NI9xOKKQU
hiWxmYmyyna3zliDGlqtlw113JFTNQYl1k1yi4JQPu2gnj8te9nB0yv0RVxvbTOq
u8K1j9Xmj8XVpcKftusQsZ2xu52ONj3ZOOf22wE4Y6mdQcps+rN6XTHRBn7a5b0v
ZCvWf4CIttdIh51pZUIbZKHTU51uU7AhTCY/wEUtiHwYTT9Wiy9Lmay5Lh2s2PCz
yPE5Y970nOzlSCUl3bVgY1t0xbQtaO5AJ/iuw/vNw+YAiAIPNDUcbcK5njb//+0E
uTEtDA6SHeYfsNXGDzxipueKXFHfJLCTXnnT5/1v+wKCAQEA0pF78uNAQJSGe8B9
F3waDnmwyYvzv4q/J00l19edIniLrJUF/uM2DBFa8etOyMchKU3UCJ9MHjbX+EOd
e19QngGoWWUD/VwMkBQPF7dxv+QDZwudGmLl3+qAx+Uc8O4pq3AQmQJYBq0jEpd/
Jv0rpk3f2vPYaQebW8+MrpIWWASK+1QLWPtdD0D9W61uhVTkzth5HF9vbuSXN01o
Mwd6WxPFSJRQCihAtui3zV26vtw7sv+t7pbPhT2nsx85nMdBOzXmtQXi4Lz7RpeM
XgaAJi91g6jqfIcQo7smHVJuLib9/pWQhL2estLBTzUcocced2Mh0Y+xMofSZFF7
J2E5mwKCAQAO9npbUdRPYM0c7ZsE385C42COVobKBv5pMhfoZbPRIjC3R3SLmMwK
iWDqWZrGuvdGz79iH0xgf3suyNHwk4dQ2C9RtzQIQ9CPgiHqJx7GLaSSfn3jBkAi
me7+6nYDDZl7pth2eSFHXE/BaDRUFr2wa0ypXpRnDF78Kd8URoW6uB2Z1QycSGlP
d/w8AO1Mrdvykozix9rZuCJO1VByMme350EaijbwZQHrQ8DBX3nqp//dQqYljWPJ
uDv703S0TWcO1LtslvJaQ1aDEhhVsr7Z48dvRGvMdifg6Q29hzz5wcMJqkqrvaBc
Wr0K3v0gcEzDey0JvOxRnWj/5KyChqnXAoIBAQDq6Dsks6BjVP4Y1HaA/NWcZxUU
EZfNCTA19jIHSUiPbWzWHNdndrUq33HkPorNmFaEIrTqd/viqahr2nXpYiY/7E+V
cpn9eSxot5J8DB4VI92UG9kixxY4K7QTMKvV43Rt6BLosW/cHxW5XTNhB4JDK+TO
NlHH48fUp2qJh7/qwSikDG130RVHKwK/5Fv3NQyXTw1/n9bhnaC4eSvV39CNSeb5
rWNEZcnc9zHT2z1UespzVTxVy4hscrkssXxcCq4bOF4bnDFjfblE43o/KrVr2/Ub
jzpXQrAwXNq7pAkIpin0v40lCeTMosSgQLFqMWmtmlCpBVkyEAc9ZYXc3Vs0
-----END RSA PRIVATE KEY-----
================================================
FILE: .examples/docker-compose-mtls/certs/server/ca.crt
================================================
-----BEGIN CERTIFICATE-----
MIIE9DCCAtygAwIBAgIUCXgA3IbeA2mn8DQ0E5IxaKBLtf8wDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTE5MzRaFw0zNDA0MjMw
MTE5MzRaMBIxEDAOBgNVBAMMB2V4YW1wbGUwggIiMA0GCSqGSIb3DQEBAQUAA4IC
DwAwggIKAoICAQDLE4aTrVJrAVYksFJt5fIVhEJT5T0cLqvtDRf9hXA5Gowremsl
VJPBm4qbdImzJZCfCcbVjFEBw8h9xID1JUqRWjJ8BfTnpa4qc1e+xRtnvC+OsUeT
CCgZvK3TZ5vFsaEbRoNGuiaNq9WSTfjLwTxkK6C3Xogm9uDx73PdRob1TNK5A9mE
Ws3ZyV91+g1phKdlNMRaK+wUrjUjEMLgr0t5A5t6WKefsGrFUDaT3sye3ZxDYuEa
ljt+F8hLVyvkDBAhh6B4S5dQILjp7L3VgOsG7Hx9py1TwCbpWXZEuee/1/2OD8tA
ALsxkvRE1w4AZzLPYRL/dOMllLjROQ4VugU8GVpNU7saK5SeWBw3XHyJ9m8vne3R
cPWaZTfkwfj8NjCgi9BzBPW8/uw7XZMmQFyTj494OKM3T5JQ5jZ5XD97ONm9h+C/
oOmkcWHz6IwEUu7XV5IESxiFlrq8ByAYF98XPhn2wMMrm2OvHMOwrfw2+5U8je5C
z70p9kpiGK8qCyjbOl9im975jwFCbl7LSj3Y+0+vRlTG/JA4jNZhXsMJcAxeJpvr
pmm/IzN+uXNQzmKzBHVDw+mTUMPziRsUq4q6WrcuQFZa6kQFGNYWI/eWV8o4AAvp
HtrOGdSyU19w0QqPW0wHmhsV2XFcn6H/E1Qg6sxWpl45YWJFhNaITxm1EQIDAQAB
o0IwQDAOBgNVHQ8BAf8EBAMCAgQwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
bh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcNAQELBQADggIBAKvOh81Gag0r
0ipYS9aK6rp58b6jPpF6shr3xFiJQVovgSvxNS3aWolh+ZupTCC3H2Q1ZUgatak0
VyEJVO4a7Tz+1XlA6KErhnORC6HB/fgr5KEGraO3Q1uWonPal5QU8xHFStbRaXfx
hl/k4LLhIdJqcJE+XX/AL8ekZ3NPDtf9+k4V+RBuarLGuKgOtBB8+1qjSpClmW2B
DaWPlrLPOr2Sd29WOeWHifwVc6kBGpwM3g5VGdDsNX4Ba5eIG3lX2kUzJ8wNGEf0
bZxcVbTBY+D4JaV4WXoeFmajjK3EdizRpJRZw3fM0ZIeqVYysByNu/TovYLJnBPs
5AybnO4RzYONKJtZ1GtQgJyG+80/VffDJeBmHKEiYvE6mvOFEBAcU4VLU6sfwfT1
y1dZq5G9Km72Fg5kCuYDXTT+PB5VAV3Z6k819tG3TyI4hPlEphpoidRbZ+QS9tK5
RgHah9EJoM7tDAN/mUVHJHQhhLJDBn+iCBYgSJVLwoE+F39NO9oFPD/ZxhJkbk9b
LkFnpjrVbwD1CNnawX3I2Eytg1IbbzyviQIbpSAEpotk9pCLMAxTR3a08wrVMwst
2XVSrgK0uUKsZhCIc+q21k98aeNIINor15humizngyBWYOk8SqV84ZNcD6VlM3Qv
ShSKoAkdKxcGG1+MKPt5b7zqvTo8BBPM
-----END CERTIFICATE-----
================================================
FILE: .examples/docker-compose-mtls/certs/server/server.crt
================================================
-----BEGIN CERTIFICATE-----
MIIFDjCCAvagAwIBAgITc5Ejz7RzBJ2/PcUMsVhj41RtQDANBgkqhkiG9w0BAQsF
ADASMRAwDgYDVQQDDAdleGFtcGxlMB4XDTI0MDQyNTAxNDQ1N1oXDTI5MDQyNDAx
NDQ1N1owEDEOMAwGA1UEAwwFbmdpbngwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
ggIKAoICAQCgbLBnVrBdRkBF2XmJgDTiRqWFPQledzCrkHF4eiUvtEytJhkpoRv2
+SiRPsjCo3XjwcgQIgSy1sHUV8Sazn7V5ux/XBRovhdhUivzI8JSRYj6qwqdUnOy
dG1ZEy/VRLsIVfoFB0jKJrZCXMT256xkYTlsgPePDsduO7IPPrTN0/I/qBvINFet
zgWCl2qlZgF4c/MHljo2TR1KlBv0RJUZbfXPwemUazyMrh/MfQHaHE5pfrmMWFGA
6yLYHEhG+fy5d3F/1+4J24D2j7deIFmmuJMPSlAPt1UjDm7M/bmoTxDG+1MRXSnN
647EzzS0TFZspHe2+yBbw6j0MMiWMzNZX2iXGVcswXwrphe7ro6OITynM76gDTuM
ISYXKYHayqW0rHFRlKxMcnmrpf5tBuK7XKyoQv/LbFKI1e+j1bNVe7OZtC88EWRc
SD8WDLqo/3rsxJkRXRW/49hO1nynHrknXJEpZeRnTyglS+VCzXYD0XzwzPKN7CyN
CHpYpOcWrAMF+EJnE4WRVyJAAt4C1pGhiwn0yCvLEGXXedI/rR5zmUBKitSe7oMT
J82H/VaGtwH0lOD9Jjsv9cb+s1c3tChPDKvgGGDaFnlehKg9TM7p+xc9mnEsitfv
ovSGzYHk29nQu/S4QrPfWuCNwM2vP9OQ+VJyzDzSyH8iuPPmkfmK5wIDAQABo18w
XTAbBgNVHREEFDASggVuZ2lueIIJbG9jYWxob3N0MB0GA1UdDgQWBBT89oboWPBC
oNsSbaNquzrjTza6xDAfBgNVHSMEGDAWgBRuH1ODijHGcclNJprRYsFN6xjDETAN
BgkqhkiG9w0BAQsFAAOCAgEAeg8QwBTne1IGZMDvIGgs95lifzuTXGVQWEid7VVp
MmXGRYsweb0MwTUq3gSUc+3OPibR0i5HCJRR04H4U+cIjR6em1foIV/bW6nTaSls
xQAj92eMmzOo/KtOYqMnk//+Da5NvY0myWa/8FgJ7rK1tOZYiTZqFOlIsaiQMHgp
/PEkZBP5V57h0PY7T7tEj4SCw3DJ6qzzIdpD8T3+9kXd9dcrrjbivBkkJ23agcG5
wBcI862ELNJOD7p7+OFsv7IRsoXXYrydaDg8OJQovh4RccRqVEQu3hZdi7cPb8xJ
G7Gxn8SfSVcPg/UObiggydMl8E8QwqWAzJHvl1KUECd5QG6eq984JTR7zQB2iGb6
1qq+/d9uciuB2YY2h/0rl3Fjy6J6k3fpQK577TlJjZc0F4WH8fW5bcsyGTszxQLI
jQ6FuSOr55lZ9O3R3+95tAdJTrWsxX7j7xMIAXSYrfNt5HM91XNhqISF4SIZOBB6
enVrrJ/oCFqVSbYf6RVQz3XmPEEMh+k9KdwvIvwoS9NivLD3QH0RjhTyzHbf+LlR
rWM46XhmBwajlpnIuuMp6jZcXnbhTO1SheoRVMdijcnW+zrmx5oyn3peCfPqOVLz
95YfJUIFCt+0p/87/0Mm76uVemK6kFKZJQPnfbAdsKF7igPZfUQx6wZZP1qK9ZEU
eOk=
-----END CERTIFICATE-----
================================================
FILE: .examples/docker-compose-mtls/certs/server/server.key
================================================
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEAoGywZ1awXUZARdl5iYA04kalhT0JXncwq5BxeHolL7RMrSYZ
KaEb9vkokT7IwqN148HIECIEstbB1FfEms5+1ebsf1wUaL4XYVIr8yPCUkWI+qsK
nVJzsnRtWRMv1US7CFX6BQdIyia2QlzE9uesZGE5bID3jw7HbjuyDz60zdPyP6gb
yDRXrc4FgpdqpWYBeHPzB5Y6Nk0dSpQb9ESVGW31z8HplGs8jK4fzH0B2hxOaX65
jFhRgOsi2BxIRvn8uXdxf9fuCduA9o+3XiBZpriTD0pQD7dVIw5uzP25qE8QxvtT
EV0pzeuOxM80tExWbKR3tvsgW8Oo9DDIljMzWV9olxlXLMF8K6YXu66OjiE8pzO+
oA07jCEmFymB2sqltKxxUZSsTHJ5q6X+bQbiu1ysqEL/y2xSiNXvo9WzVXuzmbQv
PBFkXEg/Fgy6qP967MSZEV0Vv+PYTtZ8px65J1yRKWXkZ08oJUvlQs12A9F88Mzy
jewsjQh6WKTnFqwDBfhCZxOFkVciQALeAtaRoYsJ9MgryxBl13nSP60ec5lASorU
nu6DEyfNh/1WhrcB9JTg/SY7L/XG/rNXN7QoTwyr4Bhg2hZ5XoSoPUzO6fsXPZpx
LIrX76L0hs2B5NvZ0Lv0uEKz31rgjcDNrz/TkPlScsw80sh/Irjz5pH5iucCAwEA
AQKCAgADiEEeFV+OvjQ+FXrCl0sSzGFqnJxvMwqkTGrjLzVQZpTlnxggvYZjGrtU
71/2QSkgWazxBf66fVYJOeF/Uxqh1RLR/xIH+F+FagzDrr7hltxcQJXcPuuDO2MI
+g4skPXZSiNWJwHoSY/ryCUiFpnKIAXmqLRKtxWXDMNv6H6MpaUI18e80cI4dnfS
l0jm2Wcg4tSwDxO7DFmfwcEX0MbDp5Mo/ukIto+/vTnAA+Sdi9ACLKMjPvKUdxju
TzkcLvbskn+yQ+ve1bFyPFnaPbYboKbESGuY3P2H5xJzewayeQMyjmgW0slP2mbr
WHCdo6ynebuVENR2kMlQjx5riDcSMMX5TLGPgNL7ZBf2b52mUgFyQb27eO2WXeyH
YLtInlKA44bdi76sDK+s8zYywZnxsUy7xrKhHE5rqz964EfoLRcY/fCm7XnMo6uK
VviBtdPebsMqkZOUKSaYSRpUgXILTud5FD+m68FeVjUvQFQqHYEa3gx+rAIjKBIn
082NzfDZSHVsvG+iB5q+37R8C0/YUzSb3TXys5pA82YsjIFeQiVE4hrV1yeNIZf6
2iaPD/r5H3vt0rFEDINZafC+6bTTRQoq8TOCZFh/Lu+ynXKOPrVUF8/y3sd8+T2v
kRDOL37reUotjE1lbO4RhLgHbeWHlT/PPnF7RDKCe6/erg2MqQKCAQEAy3f8B6I8
7CP4CZmMDWwHWsjMS/HGZgvPPbmWhaeZZmFyYi7I8MruJPhlhlw6YoUIV9Vvp8zE
eLtDvZ5WXuL38aRElWzNyrhrU1/vH4pkaFk+OgRcaleGUof+go0lE8BIYnWoWovo
/F7lQMQmHY4SuwF4oj6dpus7jMm41PQqDTsjofdLgwVAGy30LIkVt8qYha77sL8N
0ohXomDGik0nVa+i2mOJ0UuooGYF8WhujzVcELcerYvvg9kFDqJaEXdfTx4DRwiz
6f5gSbZHME7moqEkcJRtwj8TXSJYRHTI8ngS0xzyV0u2RL3FOxTcgikJIkmU6W3L
IcbP6XVlrCdoswKCAQEAydfBcsYcS2mMqCOdKkGVj6zBriT78/5dtPYeId9WkrnX
1vz6ErjHQ8vZkduvCm3KkijQvva+DFV0sv24qTyA2BIoDUJdk7cY962nR4Q9FHTX
Dkn1kgeKg4TtNdgo2KsIUn7bCibKASCExo6rO3PWiQyF+jTJVDD3rXx7+7N7WJaz
zTVt6BNOWoIjTufdXfRWt3wi0H6sSkqvRWoIAaguXkKXH7oBx0gKs+oAVovFvg7A
LLEtTszsv2LmbpGWaiT3Ny215mA0ZGI9T4utK7oUgd+DlV0+vj5tFfsye4COpCyG
V/ZQ7CBbxHDDak3R3fYy5pOwmh6814wHMyKKfdGm/QKCAQEAiW4Pk3BnyfA5lvJZ
gK9ZAF7kbt9tbHvJjR2Pp9Meb+KeCecj3lCTLfGBUZF19hl5GyqU8jgC9LE3/hm2
qPyREGwtzufg0G5kP7pqn1kwnLK6ryFG8qUPmys0IyYGxyJ3QdnKzu31fpDyNB7I
x+mwiRNjUeMNRTNZ06xk5aHNzYYGeV25aVPgivstE++79ZooDxOz+Rvy0CM7XfgT
4lJeoSeyzeOxsOZzjXObzAUHuD8IYlntpLcCHoI1Qj8yqt2ASMYy3IXqT8B7dQ5j
YyPH8Ez7efcnc656+8s453QiTnP/8wx4O7Jt+FxdnZxnnJrvCnO82zZHoBbTVBLx
i6hKtQKCAQA0j3SWmLRBhwjTuAJzQITb1xbQbF0X2oM4XmbWVzxKFQ75swLD4U4y
f2D2tIhOZOy9RtelAsfWmmI7QgrWNyUuHvxDB6cqkiF0Tcoju3HUY+CknenOzxvo
x7KltNZeJZuTL+mGKTetN3Sb6Ab7Al05bwNsdlZ/EAlPKf13O/PAy+2iYGlwZ6ad
twnOwF5K2xfBzBecx3/CENS3dLcFB3CbpyeHYX6ZEE+JLkRMRTWHGnw8px6vSHnW
FMEAxfSvS1T9D3Awv5ilE1f34N2FZ31znGq9eHygOc1aTgGFW6LJabbKLSBBfOOo
sdyRUBZ4gGYc2RTB7YMrdhFh5Xq+7NtZAoIBAQCOJ3CLecp/rS+lGy7oyx4f6QDd
zH/30Y/uvXLPUj+Ljg9bMTG9chjaKfyApXv6rcQI0d6wrqAunNl1b3opBQjsGCSt
bpBV/rGg3sl752og6KU1PCZ2KkVYPjugNhqPGonNh8tlw+1xFyBdt0c68g/auIHq
WaT5tWVfP01Ri43RjyCgNtJ2TJUzbA40BteDHPWKeM1lZ6e92fJTp5IjQ/Okc41u
Elr7p22fx/N04JTX9G6oGdxM7Gh2Uf4i4PnNOi+C3xqLrtUEi/OLof2UHlatypt9
pix0bXJtZE7WfFfesQIxGffVBhgN3UgqhAf2wquHgm1O17JXrmkR6JSYNpKc
-----END RSA PRIVATE KEY-----
================================================
FILE: .examples/docker-compose-mtls/compose.yaml
================================================
services:
nginx:
image: nginx:stable
volumes:
- ./certs/server:/etc/nginx/certs
- ./nginx:/etc/nginx/conf.d
ports:
- "8443:443"
networks:
- mtls
gatus:
image: twinproduction/gatus:latest
restart: always
ports:
- "8080:8080"
volumes:
- ./config:/config
- ./certs/client:/certs
environment:
- GATUS_CONFIG_PATH=/config
networks:
- mtls
networks:
mtls:
================================================
FILE: .examples/docker-compose-mtls/nginx/default.conf
================================================
server {
listen 443 ssl;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_client_certificate /etc/nginx/certs/ca.crt;
ssl_verify_client on;
location / {
if ($ssl_client_verify != SUCCESS) {
return 403;
}
root /usr/share/nginx/html;
index index.html index.htm;
}
}
================================================
FILE: .examples/docker-compose-multiple-config-files/compose.yaml
================================================
services:
gatus:
image: twinproduction/gatus:latest
ports:
- "8080:8080"
environment:
- GATUS_CONFIG_PATH=/config
volumes:
- ./config:/config
================================================
FILE: .examples/docker-compose-multiple-config-files/config/backend.yaml
================================================
endpoints:
- name: check-if-api-is-healthy
group: backend
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 1000"
- name: check-if-website-is-pingable
url: "icmp://example.org"
interval: 1m
conditions:
- "[CONNECTED] == true"
- name: check-domain-expiration
url: "https://example.org"
interval: 6h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
================================================
FILE: .examples/docker-compose-multiple-config-files/config/frontend.yaml
================================================
endpoints:
- name: make-sure-html-rendering-works
group: frontend
url: "https://example.org"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY] == pat(*
Example Domain *)" # Check for header in HTML page
================================================
FILE: .examples/docker-compose-multiple-config-files/config/global.yaml
================================================
metrics: true
ui:
header: Example Company
link: https://example.org
buttons:
- name: "Home"
link: "https://example.org"
================================================
FILE: .examples/docker-compose-postgres-storage/compose.yaml
================================================
services:
postgres:
image: postgres
volumes:
- ./data/db:/var/lib/postgresql/data
ports:
- "5432:5432"
environment:
- POSTGRES_DB=gatus
- POSTGRES_USER=username
- POSTGRES_PASSWORD=password
networks:
- web
gatus:
image: twinproduction/gatus:latest
restart: always
ports:
- "8080:8080"
environment:
- POSTGRES_USER=username
- POSTGRES_PASSWORD=password
- POSTGRES_DB=gatus
volumes:
- ./config:/config
networks:
- web
depends_on:
- postgres
networks:
web:
================================================
FILE: .examples/docker-compose-sqlite-storage/compose.yaml
================================================
services:
gatus:
image: twinproduction/gatus:latest
ports:
- "8080:8080"
volumes:
- ./config:/config
- ./data:/data/
================================================
FILE: .examples/docker-compose-sqlite-storage/data/.gitkeep
================================================
================================================
FILE: .examples/docker-minimal/Dockerfile
================================================
FROM twinproduction/gatus
ADD config.yaml ./config/config.yaml
================================================
FILE: .examples/kubernetes/gatus.yaml
================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: gatus
namespace: kube-system
data:
config.yaml: |
metrics: true
endpoints:
- name: website
url: https://twin.sh/health
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- name: github
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- name: cat-fact
url: "https://cat-fact.herokuapp.com/facts/random"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].deleted == false"
- "len([BODY].text) > 0"
- "[BODY].text == pat(*cat*)"
- "[STATUS] == pat(2*)"
- "[CONNECTED] == true"
- name: example
url: https://example.com/
conditions:
- "[STATUS] == 200"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: gatus
namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gatus
namespace: kube-system
spec:
replicas: 1
selector:
matchLabels:
app: gatus
template:
metadata:
name: gatus
namespace: kube-system
labels:
app: gatus
spec:
serviceAccountName: gatus
terminationGracePeriodSeconds: 5
containers:
- image: twinproduction/gatus
imagePullPolicy: IfNotPresent
name: gatus
ports:
- containerPort: 8080
name: http
protocol: TCP
resources:
limits:
cpu: 250m
memory: 100M
requests:
cpu: 50m
memory: 30M
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
successThreshold: 1
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
failureThreshold: 5
volumeMounts:
- mountPath: /config
name: gatus-config
volumes:
- configMap:
name: gatus
name: gatus-config
---
apiVersion: v1
kind: Service
metadata:
name: gatus
namespace: kube-system
spec:
ports:
- name: http
port: 8080
protocol: TCP
targetPort: 8080
selector:
app: gatus
================================================
FILE: .examples/nixos/README.md
================================================
# NixOS
Gatus is implemented as a NixOS module. See [gatus.nix](./gatus.nix) for example
usage.
================================================
FILE: .examples/nixos/gatus.nix
================================================
{
services.gatus = {
enable = true;
settings = {
web.port = 8080;
endpoints = [
{
name = "website";
url = "https://twin.sh/health";
interval = "5m";
conditions = [
"[STATUS] == 200"
"[BODY].status == UP"
"[RESPONSE_TIME] < 300"
];
}
];
};
};
}
================================================
FILE: .gitattributes
================================================
* text=auto eol=lf
================================================
FILE: .github/FUNDING.yml
================================================
github: [TwiN]
================================================
FILE: .github/assets/gatus-diagram.drawio
================================================
7Vxbc6M2FP41frQHxDWPuW13Z9JpZtKddPdNAQWrBcQKObH76ytsyVxkHOxgC6bOS+BICPl856ZzJCbWbbL8jcJs/jsJUTwBRricWHcTAEzDBfxfQVkJiu9ZG0pEcShoJeEJ/4vko4K6wCHKax0ZITHDWZ0YkDRFAavRIKXkvd7tlcT1t2YwQgrhKYCxSn3GIZsLKjCMsuErwtFcvvoKiJYEyt6CkM9hSN4rJOt+Yt1SQtjmKlneorhgn2TM5rkvLa3bmVGUsi4PfMHmwwP9av/5bDqPP9Obbw/4j6kn5sZW8hejkDNA3BLK5iQiKYzvS+oNJYs0RMWoBr8r+zwQknGiyYl/I8ZWAk24YIST5iyJRSufMF39JZ5f3/wobmaOvL1bVhvvVuLulaRMDGr6/H4z92LCrSwRpJwsaID28EHKFqQRYnv6uVvguMwjkiA+P/4cRTFk+K0+DyhkL9r2K9HhFwKgA8AS477BeCHe9D1HVEGQC1pWXC6S+DpghHJOvSHKMJfqB/iC4keSY4ZJyru8EMZIUulwHeOoaGAFlFXMyILFOEW3Wz0ztgAUz6LlfghUlokHHKEdwkA4UvHfS22zBGle0TPXOBGPry4KUQp6B4XwdSoEsBSNmAA3ZoI1NRzdXwsiG6b5mmnXvINpZ8s152Q7v4qK/zgvWLnMSM6RBcbLSo7MZ7oZfNNvp7ys9ayOMRSaFXAoEN2hcgkOw404IT49+LIer0A5Izhla9Y5NxPnrhX3HXoo3KIYrPRFVYloV4NWpZ0aM9Mz7c1YnaEWwz0WP6ccywQ1AzCV93IE8vqacwlsisp2UsdLj/t54fFbhEcOlGcwlbTv3yoSVG3oKlh1M/M+xww9ZXCtxe88/KqL21pExLNmq3x0t9Om/bGhNsE5LbWpukPKB8boDRWqG0IGi4iPcv+m1aSXVvxHte38Jt3vaNJNY7ckHKbo15TCVaWDsGKtdsB26wJmXzXC2cP684vNDHo1Gb4icjwgYpTEMaJ5DwpbAb5vBbaNoSkwcMcaa9k9Lz6MjpoJHK3Rlvd5hwn2RVs4yWKUFPHRoCOuEv0+Iy7QsrQsQy4LeE4jVuonAvMbozZGOF0EJgW/IlFPfMFa5GRGZUstX7ctNR2Fk6i4hgypfun/Gf7IhcXH8Y+t08pKbRyfV9SGl6UVr0vG6FC8Pru++FwUo/qcjAfvOGfrECTVHV34a/FgUGRo96ZZjw42zI/zO7ZfDzZAL7HGtJ7tOV+yR76pAvszZME8JNGwY41m4mWbMdcWa1haLN4pLZfV0XJZWrPdpprtvo4Lo5MOXISb4fJWXPWFy7bCyvs0LFNUw+Vl0xzo56X8DRVeMoqjaFdGbOS+dD80ltWw1KYxMwzbND3X9gzHd+zDvSUfoPZn197gnst7WpYOkz/AIBc4HV0FaImwzuMqLPuC12F4WS26fSa8nAteB+KldxGpptt6qx0XGVB0eE3YPCw22ZvE7u7zXHtomVBwdTpoEpQQ2rkscXTcCPrBxm9i43XEBpxs5ajmXnrDJiM5i2iR7R4nOralHR01kO8NnfxXzBk8WmwM3dg4ow3oSkh6CRCsrrtYbK1VHGe0AZ02vLTubXBGuxdFG15at8I7oz24oA0vTyteo61qa8NLa63BGW1VWxteWhN+cjl9wasrXo7WBJJrXvA6EC+tCVp5kPWCV2e8gFa8Rluw0oaX1l10lnqy4pGSNxzuON96dM5771G543Pe+kvwdg/nIC878z8E3vHcOvD2yQBV96cEi5ztOMo2tFRpV104WabUVktzIc4DQsPB867rzr7T8U49EosSiOPBc65r1fF0nFPPRkWERDEK5pANnn1m17OAp+Of6v8TyCheDp93tnbeqRVvzjvuWBOSj0D2tFelpdmt8C9lr6vBcw7or0qqFePig0g0XLARsE97wd0BCvvyGAb/DJ91rnbWqSsexILZbDZ83l3p5p2ruRZz/InQnnMjrlhlDfurX57qnpqLe/nZPLl2t3Zsj6KE8dVv5+1RZ16571W2o4+5uS01NDF8ccrtCtS0c9rPR42m9X36Z/yokae5sDCY496dlVtrndVT/VipqXpV8jQfFvP2e0tjBky/oTzW51RSDnO2wzKumrx7QsGC4j6D4ubZMqMVqu6hCvDqoYrbdWl7RGKb35afPt1wvvyErHX/Hw==
================================================
FILE: .github/codecov.yml
================================================
ignore:
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
- "watchdog/endpoint.go"
- "watchdog/external_endpoint.go"
- "watchdog/suite.go"
- "watchdog/watchdog.go"
comment: false
coverage:
status:
patch: off
project:
default:
target: 70%
threshold: null
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
labels: ["dependencies"]
schedule:
interval: "daily"
- package-ecosystem: "gomod"
directory: "/"
open-pull-requests-limit: 3
labels: ["dependencies"]
schedule:
interval: "daily"
================================================
FILE: .github/workflows/benchmark.yml
================================================
name: benchmark
on:
workflow_run:
workflows: [publish-latest]
branches: [master]
types: [completed]
workflow_dispatch:
inputs:
repository:
description: "Repository to checkout. Useful for benchmarking a fork. Format should be /."
required: true
default: "TwiN/gatus"
ref:
description: "Branch, tag or SHA to checkout"
required: true
default: "master"
jobs:
build:
name: benchmark
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/setup-go@v6
with:
go-version: 1.25.5
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v5
- name: Benchmark
run: go test -bench=. ./storage/store
================================================
FILE: .github/workflows/labeler.yml
================================================
name: labeler
on:
pull_request_target:
types:
- opened
issues:
types:
- opened
jobs:
labeler:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
pull-requests: write
steps:
- name: Label
continue-on-error: true
env:
TITLE: ${{ github.event.issue.title }}${{ github.event.pull_request.title }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}${{ github.event.pull_request.number }}
run: |
if [[ $TITLE == "feat"* ]]; then
gh issue edit "$NUMBER" --add-label "feature"
elif [[ $TITLE == "fix"* ]]; then
gh issue edit "$NUMBER" --add-label "bug"
elif [[ $TITLE == "docs"* ]]; then
gh issue edit "$NUMBER" --add-label "documentation"
fi
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
gh issue edit "$NUMBER" --add-label "area/alerting"
fi
if [[ $TITLE == *"(ui)"* || $TITLE == *"ui:"* ]]; then
gh issue edit "$NUMBER" --add-label "area/ui"
fi
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
gh issue edit "$NUMBER" --add-label "area/storage"
fi
if [[ $TITLE == *"security"* || $TITLE == *"oidc"* || $TITLE == *"oauth2"* ]]; then
gh issue edit "$NUMBER" --add-label "area/security"
fi
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
gh issue edit "$NUMBER" --add-label "area/metrics"
fi
================================================
FILE: .github/workflows/publish-custom.yml
================================================
name: publish-custom
run-name: "${{ inputs.tag }}"
on:
workflow_dispatch:
inputs:
tag:
description: Custom tag to publish
platforms:
description: Platforms to publish to (comma separated list)
default: linux/amd64
type: choice
options:
- linux/amd64
- linux/arm/v7
- linux/arm64
jobs:
publish-custom:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Get image repository
run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=${{ inputs.tag }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: ${{ inputs.platforms }}
pull: true
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
================================================
FILE: .github/workflows/publish-experimental.yml
================================================
name: publish-experimental
on: [workflow_dispatch]
jobs:
publish-experimental:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_REPOSITORY }}
tags: |
type=raw,value=experimental
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
pull: true
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
================================================
FILE: .github/workflows/publish-latest.yml
================================================
name: publish-latest
on:
workflow_run:
workflows: [test]
branches: [master]
types: [completed]
concurrency:
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
cancel-in-progress: true
jobs:
publish-latest:
runs-on: ubuntu-latest
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
timeout-minutes: 240
steps:
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Get image repository
run: |
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKER_IMAGE_REPOSITORY }}
${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
================================================
FILE: .github/workflows/publish-release.yml
================================================
name: publish-release
on:
release:
types: [published]
jobs:
publish-release:
name: publish-release
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Get image repository
run: |
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Login to Docker Registry
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKER_IMAGE_REPOSITORY }}
${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=${{ env.RELEASE }}
type=raw,value=stable
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
================================================
FILE: .github/workflows/regenerate-static-assets.yml
================================================
name: regenerate-static-assets
on:
issue_comment:
types: [created]
jobs:
check-command:
runs-on: ubuntu-latest
if: ${{ github.event.issue.pull_request }}
permissions:
pull-requests: write # required for adding reactions to command comments on PRs
checks: read # required to check if all ci checks have passed
outputs:
continue: ${{ steps.command.outputs.continue }}
steps:
- name: Check command trigger
id: command
uses: github/command@v2
with:
command: "/regenerate-static-assets"
permissions: "write,admin" # The allowed permission levels to invoke this command
allow_forks: true
allow_drafts: true
skip_ci: true
skip_completing: true
regenerate-static-assets:
runs-on: ubuntu-latest
needs: check-command
if: ${{ needs.check-command.outputs.continue == 'true' }}
permissions:
contents: write
outputs:
status: ${{ steps.commit.outputs.status }}
steps:
- name: Get PR branch
id: pr
uses: actions/github-script@v8
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
core.setOutput('ref', pr.data.head.ref);
core.setOutput('repo', pr.data.head.repo.full_name);
- name: Checkout PR branch
uses: actions/checkout@v6
with:
repository: ${{ steps.pr.outputs.repo }}
ref: ${{ steps.pr.outputs.ref }}
- name: Regenerate static assets
run: |
make frontend-install-dependencies
make frontend-build
- name: Commit and push changes
id: commit
run: |
echo "Checking for changes..."
if git diff --quiet; then
echo "No changes detected."
echo "status=no_changes" >> $GITHUB_OUTPUT
exit 0
fi
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
echo "Changes detected. Committing and pushing..."
git add .
git commit -m "chore(ui): Regenerate static assets"
git push origin ${{ steps.pr.outputs.ref }}
echo "status=success" >> $GITHUB_OUTPUT
create-response-comment:
runs-on: ubuntu-latest
needs: [check-command, regenerate-static-assets]
if: ${{ !cancelled() && needs.check-command.outputs.continue == 'true' }}
permissions:
pull-requests: write
steps:
- name: Create response comment
uses: actions/github-script@v8
with:
script: |
const status = '${{ needs.regenerate-static-assets.outputs.status }}';
let reaction = 'hooray';
let message = '';
var workflowUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
if (status === 'no_changes') {
message = `@${context.actor} No changes to commit ([ref](${workflowUrl})).`;
} else {
reaction = '-1';
message = `@${context.actor} There was an issue regenerating static assets. Please check the [workflow run logs](${workflowUrl}) for more details.`;
}
if (message.length) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
}
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: reaction
});
================================================
FILE: .github/workflows/test-ui.yml
================================================
name: test-ui
on:
pull_request:
paths:
- 'web/**'
push:
branches:
- master
paths:
- 'web/**'
jobs:
test-ui:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- run: make frontend-install-dependencies
- run: make frontend-build
================================================
FILE: .github/workflows/test.yml
================================================
name: test
on:
pull_request:
paths-ignore:
- '*.md'
- '.examples/**'
push:
branches:
- master
paths-ignore:
- '*.md'
- '.github/**'
- '.examples/**'
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/setup-go@v6
with:
go-version: 1.25.5
- uses: actions/checkout@v5
- name: Build binary to make sure it works
run: go build
- name: Test
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v5.5.2
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
================================================
FILE: .gitignore
================================================
# IDE
*.iml
.idea
.vscode
# OS
.DS_Store
# JS
node_modules
# Go
/vendor
# Misc
*.db
*.db-shm
*.db-wal
gatus
config/config.yml
config.yaml
================================================
FILE: Dockerfile
================================================
# Build the go application into a binary
FROM golang:alpine AS builder
RUN apk --update add ca-certificates
WORKDIR /app
COPY . ./
RUN go mod tidy -diff
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
# Run Tests inside docker image if you don't have a configured go environment
#RUN apk update && apk add --virtual build-dependencies build-base gcc
#RUN go test ./... -mod vendor
# Run the binary on an empty container
FROM scratch
COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV GATUS_CONFIG_PATH=""
ENV GATUS_LOG_LEVEL="INFO"
ENV PORT="8080"
EXPOSE ${PORT}
ENTRYPOINT ["/gatus"]
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: Makefile
================================================
BINARY=gatus
.PHONY: install
install:
go build -v -o $(BINARY) .
.PHONY: run
run:
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml go run main.go
.PHONY: run-binary
run-binary:
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
.PHONY: clean
clean:
rm $(BINARY)
.PHONY: test
test:
go test ./... -cover
##########
# Docker #
##########
docker-build:
docker build -t twinproduction/gatus:latest .
docker-run:
docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
docker-build-and-run: docker-build docker-run
#############
# Front end #
#############
frontend-install-dependencies:
npm --prefix web/app install
frontend-build:
npm --prefix web/app run build
frontend-run:
npm --prefix web/app run serve
================================================
FILE: README.md
================================================
[](https://gatus.io)

[](https://goreportcard.com/report/github.com/TwiN/gatus)
[](https://codecov.io/gh/TwiN/gatus)
[](https://github.com/TwiN/gatus)
[](https://cloud.docker.com/repository/docker/twinproduction/gatus)
[](https://github.com/TwiN)
Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
queries as well as evaluate the result of said queries by using a list of conditions on values like the status code,
the response time, the certificate expiration, the body and many others. The icing on top is that each of these health
checks can be paired with alerting via Slack, Teams, PagerDuty, Discord, Twilio and many more.
I personally deploy it in my Kubernetes cluster and let it monitor the status of my
core applications: https://status.twin.sh/
_Looking for a managed solution? Check out [Gatus.io](https://gatus.io)._
Quick start
```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)
> ❤ Like this project? Please consider [sponsoring me](https://github.com/sponsors/TwiN).

Have any feedback or questions? [Create a discussion](https://github.com/TwiN/gatus/discussions/new).
## Table of Contents
- [Table of Contents](#table-of-contents)
- [Why Gatus?](#why-gatus)
- [Features](#features)
- [Usage](#usage)
- [Configuration](#configuration)
- [Endpoints](#endpoints)
- [External Endpoints](#external-endpoints)
- [Suites (ALPHA)](#suites-alpha)
- [Conditions](#conditions)
- [Placeholders](#placeholders)
- [Functions](#functions)
- [Web](#web)
- [UI](#ui)
- [Announcements](#announcements)
- [Storage](#storage)
- [Client configuration](#client-configuration)
- [Tunneling](#tunneling)
- [Alerting](#alerting)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring ClickUp alerts](#configuring-clickup-alerts)
- [Configuring Datadog alerts](#configuring-datadog-alerts)
- [Configuring Discord alerts](#configuring-discord-alerts)
- [Configuring Email alerts](#configuring-email-alerts)
- [Configuring Gitea alerts](#configuring-gitea-alerts)
- [Configuring GitHub alerts](#configuring-github-alerts)
- [Configuring GitLab alerts](#configuring-gitlab-alerts)
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
- [Configuring Gotify alerts](#configuring-gotify-alerts)
- [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts)
- [Configuring IFTTT alerts](#configuring-ifttt-alerts)
- [Configuring Ilert alerts](#configuring-ilert-alerts)
- [Configuring Incident.io alerts](#configuring-incidentio-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)
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
- [Configuring Plivo alerts](#configuring-plivo-alerts)
- [Configuring Pushover alerts](#configuring-pushover-alerts)
- [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts)
- [Configuring SendGrid alerts](#configuring-sendgrid-alerts)
- [Configuring Signal alerts](#configuring-signal-alerts)
- [Configuring SIGNL4 alerts](#configuring-signl4-alerts)
- [Configuring Slack alerts](#configuring-slack-alerts)
- [Configuring Splunk alerts](#configuring-splunk-alerts)
- [Configuring Squadcast alerts](#configuring-squadcast-alerts)
- [Configuring Teams alerts *(Deprecated)*](#configuring-teams-alerts-deprecated)
- [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts)
- [Configuring Telegram alerts](#configuring-telegram-alerts)
- [Configuring Twilio alerts](#configuring-twilio-alerts)
- [Configuring Vonage alerts](#configuring-vonage-alerts)
- [Configuring Webex alerts](#configuring-webex-alerts)
- [Configuring Zapier alerts](#configuring-zapier-alerts)
- [Configuring Zulip alerts](#configuring-zulip-alerts)
- [Configuring custom alerts](#configuring-custom-alerts)
- [Setting a default alert](#setting-a-default-alert)
- [Maintenance](#maintenance)
- [Security](#security)
- [Basic Authentication](#basic-authentication)
- [OIDC](#oidc)
- [TLS Encryption](#tls-encryption)
- [Metrics](#metrics)
- [Custom Labels](#custom-labels)
- [Connectivity](#connectivity)
- [Remote instances (EXPERIMENTAL)](#remote-instances-experimental)
- [Deployment](#deployment)
- [Docker](#docker)
- [Helm Chart](#helm-chart)
- [Terraform](#terraform)
- [Kubernetes](#kubernetes)
- [Running the tests](#running-the-tests)
- [Using in Production](#using-in-production)
- [FAQ](#faq)
- [Sending a GraphQL request](#sending-a-graphql-request)
- [Recommended interval](#recommended-interval)
- [Default timeouts](#default-timeouts)
- [Monitoring a TCP endpoint](#monitoring-a-tcp-endpoint)
- [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)
- [Monitoring an endpoint using STARTTLS](#monitoring-an-endpoint-using-starttls)
- [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)
- [Monitoring domain expiration](#monitoring-domain-expiration)
- [Concurrency](#concurrency)
- [Reloading configuration on the fly](#reloading-configuration-on-the-fly)
- [Endpoint groups](#endpoint-groups)
- [How do I sort by group by default?](#how-do-i-sort-by-group-by-default)
- [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path)
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
- [Use environment variables in config files](#use-environment-variables-in-config-files)
- [Configuring a startup delay](#configuring-a-startup-delay)
- [Keeping your configuration small](#keeping-your-configuration-small)
- [Proxy client configuration](#proxy-client-configuration)
- [How to fix 431 Request Header Fields Too Large error](#how-to-fix-431-request-header-fields-too-large-error)
- [Badges](#badges)
- [Uptime](#uptime)
- [Health](#health)
- [Health (Shields.io)](#health-shieldsio)
- [Response time](#response-time)
- [Response time (chart)](#response-time-chart)
- [How to change the color thresholds of the response time badge](#how-to-change-the-color-thresholds-of-the-response-time-badge)
- [API](#api)
- [Interacting with the API programmatically](#interacting-with-the-api-programmatically)
- [Raw Data](#raw-data)
- [Uptime](#uptime-1)
- [Response Time](#response-time-1)
- [Installing as binary](#installing-as-binary)
- [High level design overview](#high-level-design-overview)
## Why Gatus?
Before getting into the specifics, I want to address the most common question:
> Why would I use Gatus when I can just use Prometheus’ Alertmanager, Cloudwatch or even Splunk?
Neither of these can tell you that there’s a problem if there are no clients actively calling the endpoint.
In other words, it's because monitoring metrics mostly rely on existing traffic, which effectively means that unless
your clients are already experiencing a problem, you won't be notified.
Gatus, on the other hand, allows you to configure health checks for each of your features, which in turn allows it to
monitor these features and potentially alert you before any clients are impacted.
A sign you may want to look into Gatus is by simply asking yourself whether you'd receive an alert if your load balancer
was to go down right now. Will any of your existing alerts be triggered? Your metrics won’t report an increase in errors
if no traffic makes it to your applications. This puts you in a situation where your clients are the ones
that will notify you about the degradation of your services rather than you reassuring them that you're working on
fixing the issue before they even know about it.
## Features
The main features of Gatus are:
- **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address.
- **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests.
- **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor.
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty, Twilio, Google chat and Teams are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
- **Metrics**
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
- **[Badges](#badges)**:  
- **Dark mode**

## Usage
```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
```
If you want to create your own configuration, see [Docker](#docker) for information on how to mount a configuration file.
Here's a simple example:
```yaml
endpoints:
- name: website # Name of your endpoint, can be anything
url: "https://twin.sh/health"
interval: 5m # Duration to wait between every status check (default: 60s)
conditions:
- "[STATUS] == 200" # Status must be 200
- "[BODY].status == UP" # The json path "$.status" must be equal to UP
- "[RESPONSE_TIME] < 300" # Response time must be under 300ms
- name: make-sure-header-is-rendered
url: "https://example.org/"
interval: 60s
conditions:
- "[STATUS] == 200" # Status must be 200
- "[BODY] == pat(*Example Domain *)" # Body must contain the specified header
```
This example would look similar to this:

If you want to test it locally, see [Docker](#docker).
## Configuration
By default, the configuration file is expected to be at `config/config.yaml`.
You can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable.
If `GATUS_CONFIG_PATH` points to a directory, all `*.yaml` and `*.yml` files inside said directory and its
subdirectories are merged like so:
- All maps/objects are deep merged (i.e. you could define `alerting.slack` in one file and `alerting.pagerduty` in another file)
- All slices/arrays are appended (i.e. you can define `endpoints` in multiple files and each endpoint will be added to the final list of endpoints)
- Parameters with a primitive value (e.g. `metrics`, `alerting.slack.webhook-url`, etc.) may only be defined once to forcefully avoid any ambiguity
- To clarify, this also means that you could not define `alerting.slack.webhook-url` in two files with different values. All files are merged into one before they are processed. This is by design.
> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
>
> ⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
>
> See [Use environment variables in config files](#use-environment-variables-in-config-files) or [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for examples.
If you want to test it locally, see [Docker](#docker).
## Configuration
| Parameter | Description | Default |
|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
| `storage` | [Storage configuration](#storage). | `{}` |
| `alerting` | [Alerting configuration](#alerting). | `{}` |
| `announcements` | [Announcements configuration](#announcements). | `[]` |
| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` |
| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` |
| `security` | [Security configuration](#security). | `{}` |
| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | [Web configuration](#web). | `{}` |
| `ui` | [UI configuration](#ui). | `{}` |
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
The default value for `GATUS_LOG_LEVEL` is `INFO`.
### Endpoints
Endpoints are URLs, applications, or services that you want to monitor. Each endpoint has a list of conditions that are
evaluated on an interval that you define. If any condition fails, the endpoint is considered as unhealthy.
You can then configure alerts to be triggered when an endpoint is unhealthy once a certain threshold is reached.
| Parameter | Description | Default |
|:------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
| `endpoints` | List of endpoints to monitor. | Required `[]` |
| `endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
| `endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
| `endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. See [Endpoint groups](#endpoint-groups). | `""` |
| `endpoints[].url` | URL to send the request to. | Required `""` |
| `endpoints[].method` | Request method. | `GET` |
| `endpoints[].conditions` | Conditions used to determine the health of the endpoint. See [Conditions](#conditions). | `[]` |
| `endpoints[].interval` | Duration to wait between every status check. | `60s` |
| `endpoints[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`). | `false` |
| `endpoints[].body` | Request body. | `""` |
| `endpoints[].headers` | Request headers. | `{}` |
| `endpoints[].dns` | Configuration for an endpoint of type DNS. See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` |
| `endpoints[].dns.query-type` | Query type (e.g. MX). | `""` |
| `endpoints[].dns.query-name` | Query name (e.g. example.com). | `""` |
| `endpoints[].ssh` | Configuration for an endpoint of type SSH. See [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh). | `""` |
| `endpoints[].ssh.username` | SSH username (e.g. example). | Required `""` |
| `endpoints[].ssh.password` | SSH password (e.g. password). | Required `""` |
| `endpoints[].alerts` | List of all alerts for a given endpoint. See [Alerting](#alerting). | `[]` |
| `endpoints[].maintenance-windows` | List of all maintenance windows for a given endpoint. See [Maintenance](#maintenance). | `[]` |
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
| `endpoints[].ui` | UI configuration at the endpoint level. | `{}` |
| `endpoints[].ui.hide-conditions` | Whether to hide conditions from the results. Note that this only hides conditions from results evaluated from the moment this was enabled. | `false` |
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname from the results. | `false` |
| `endpoints[].ui.hide-port` | Whether to hide the port from the results. | `false` |
| `endpoints[].ui.hide-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.resolve-successful-conditions` | Whether to resolve successful conditions for the UI (helpful to expose body assertions even when checks pass). | `false` |
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
| `endpoints[].extra-labels` | Extra labels to add to the metrics. Useful for grouping endpoints together. | `{}` |
| `endpoints[].always-run` | (SUITES ONLY) Whether to execute this endpoint even if previous endpoints in the suite failed. | `false` |
| `endpoints[].store` | (SUITES ONLY) Map of values to extract from the response and store in the suite context (stored even on failure). | `{}` |
You may use the following placeholders in the body (`endpoints[].body`):
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
- `[LOCAL_ADDRESS]` (resolves to the local IP and port like `192.0.2.1:25` or `[2001:db8::1]:80`)
- `[RANDOM_STRING_N]` (resolves to a random string of numbers and letters of length N (max: 8192))
### External Endpoints
Unlike regular endpoints, external endpoints are not monitored by Gatus, but they are instead pushed programmatically.
This allows you to monitor anything you want, even when what you want to check lives in an environment that would not normally be accessible by Gatus.
For instance:
- You can create your own agent that lives in a private network and pushes the status of your services to a publicly-exposed Gatus instance
- You can monitor services that are not supported by Gatus
- You can implement your own monitoring system while using Gatus as the dashboard
| Parameter | Description | Default |
|:------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------|:---------------|
| `external-endpoints` | List of endpoints to monitor. | `[]` |
| `external-endpoints[].enabled` | Whether to monitor the endpoint. | `true` |
| `external-endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` |
| `external-endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard. See [Endpoint groups](#endpoint-groups). | `""` |
| `external-endpoints[].token` | Bearer token required to push status to. | Required `""` |
| `external-endpoints[].alerts` | List of all alerts for a given endpoint. See [Alerting](#alerting). | `[]` |
| `external-endpoints[].heartbeat` | Heartbeat configuration for monitoring when the external endpoint stops sending updates. | `{}` |
| `external-endpoints[].heartbeat.interval` | Expected interval between updates. If no update is received within this interval, alerts will be triggered. Must be at least 10s. | `0` (disabled) |
Example:
```yaml
external-endpoints:
- name: ext-ep-test
group: core
token: "potato"
heartbeat:
interval: 30m # Automatically create a failure if no update is received within 30 minutes
alerts:
- type: discord
description: "healthcheck failed"
send-on-resolved: true
```
To push the status of an external endpoint, you can use [gatus-cli](https://github.com/TwiN/gatus-cli):
```
gatus-cli external-endpoint push --url https://status.example.org --key "core_ext-ep-test" --token "potato" --success
```
or send an HTTP request:
```
POST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}
```
Where:
- `{key}` has the pattern `_` 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, this will be ignored.
- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).
You must also pass the token as a `Bearer` token in the `Authorization` header.
### Suites (ALPHA)
Suites are collections of endpoints that are executed sequentially with a shared context.
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:
- Testing multi-step authentication flows (login -> access protected resource -> logout)
- API workflows where you need to chain requests (create resource -> update -> verify -> delete)
- Monitoring business processes that span multiple services
- Validating data consistency across multiple endpoints
| Parameter | Description | Default |
|:----------------------------------|:----------------------------------------------------------------------------------------------------|:--------------|
| `suites` | List of suites to monitor. | `[]` |
| `suites[].enabled` | Whether to monitor the suite. | `true` |
| `suites[].name` | Name of the suite. Must be unique. | Required `""` |
| `suites[].group` | Group name. Used to group multiple suites together on the dashboard. | `""` |
| `suites[].interval` | Duration to wait between suite executions. | `10m` |
| `suites[].timeout` | Maximum duration for the entire suite execution. | `5m` |
| `suites[].context` | Initial context values that can be referenced by endpoints. | `{}` |
| `suites[].endpoints` | List of endpoints to execute sequentially. | Required `[]` |
| `suites[].endpoints[].store` | Map of values to extract from the response and store in the suite context (stored even on failure). | `{}` |
| `suites[].endpoints[].always-run` | Whether to execute this endpoint even if previous endpoints in the suite failed. | `false` |
**Note**: Suite-level alerts are not supported yet. Configure alerts on individual endpoints within the suite instead.
#### Using Context in Endpoints
Once values are stored in the context, they can be referenced in subsequent endpoints:
- In the URL: `https://api.example.com/users/[CONTEXT].user_id`
- In headers: `Authorization: Bearer [CONTEXT].auth_token`
- In the body: `{"user_id": "[CONTEXT].user_id"}`
- In conditions: `[BODY].server_ip == [CONTEXT].server_ip`
Note that context/store keys are limited to A-Z, a-z, 0-9, underscores (`_`), and hyphens (`-`).
#### Example Suite Configuration
```yaml
suites:
- name: item-crud-workflow
group: api-tests
interval: 5m
context:
price: "19.99" # Initial static value in context
endpoints:
# Step 1: Create an item and store the item ID
- name: create-item
url: https://api.example.com/items
method: POST
body: '{"name": "Test Item", "price": "[CONTEXT].price"}'
conditions:
- "[STATUS] == 201"
- "len([BODY].id) > 0"
- "[BODY].price == [CONTEXT].price"
store:
itemId: "[BODY].id"
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
method: PUT
body: '{"price": "24.99"}'
conditions:
- "[STATUS] == 200"
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
method: GET
conditions:
- "[STATUS] == 200"
- "[BODY].price == 24.99"
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
method: DELETE
always-run: true
conditions:
- "[STATUS] == 204"
alerts:
- type: slack
description: "Failed to delete item"
```
The suite will be considered successful only if all required endpoints pass their conditions.
### Conditions
Here are some examples of conditions you can use:
| Condition | Description | Passing values | Failing values |
|:---------------------------------|:----------------------------------------------------|:---------------------------|------------------|
| `[STATUS] == 200` | Status must be equal to 200 | 200 | 201, 404, ... |
| `[STATUS] < 300` | Status must lower than 300 | 200, 201, 299 | 301, 302, ... |
| `[STATUS] <= 299` | Status must be less than or equal to 299 | 200, 201, 299 | 301, 302, ... |
| `[STATUS] > 400` | Status must be greater than 400 | 401, 402, 403, 404 | 400, 200, ... |
| `[STATUS] == any(200, 429)` | Status must be either 200 or 429 | 200, 429 | 201, 400, ... |
| `[CONNECTED] == true` | Connection to host must've been successful | true | false |
| `[RESPONSE_TIME] < 500` | Response time must be below 500ms | 100ms, 200ms, 300ms | 500ms, 501ms |
| `[IP] == 127.0.0.1` | Target IP must be 127.0.0.1 | 127.0.0.1 | 0.0.0.0 |
| `[BODY] == 1` | The body must be equal to 1 | 1 | `{}`, `2`, ... |
| `[BODY].user.name == john` | JSONPath value of `$.user.name` is equal to `john` | `{"user":{"name":"john"}}` | |
| `[BODY].data[0].id == 1` | JSONPath value of `$.data[0].id` is equal to 1 | `{"data":[{"id":1}]}` | |
| `[BODY].age == [BODY].id` | JSONPath value of `$.age` is equal JSONPath `$.id` | `{"age":1,"id":1}` | |
| `len([BODY].data) < 5` | Array at JSONPath `$.data` has less than 5 elements | `{"data":[{"id":1}]}` | |
| `len([BODY].name) == 8` | String at JSONPath `$.name` has a length of 8 | `{"name":"john.doe"}` | `{"name":"bob"}` |
| `has([BODY].errors) == false` | JSONPath `$.errors` does not exist | `{"name":"john.doe"}` | `{"errors":[]}` |
| `has([BODY].users) == true` | JSONPath `$.users` exists | `{"users":[]}` | `{}` |
| `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` |
| `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 |
| `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... |
| `[DOMAIN_EXPIRATION] > 720h` | The domain must expire in more than 720h | 4000h | 1h, 24h, ... |
#### Placeholders
| Placeholder | Description | Example of resolved value |
|:---------------------------|:------------------------------------------------------------------------------------------|:---------------------------------------------|
| `[STATUS]` | Resolves into the HTTP status of the request | `404` |
| `[RESPONSE_TIME]` | Resolves into the response time the request took, in ms | `10` |
| `[IP]` | Resolves into the IP of the target host | `192.168.0.232` |
| `[BODY]` | Resolves into the response body. Supports JSONPath. | `{"name":"john.doe"}` |
| `[CONNECTED]` | Resolves into whether a connection could be established | `true` |
| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration (valid units are "s", "m", "h".) | `24h`, `48h`, 0 (if not protocol with certs) |
| `[DOMAIN_EXPIRATION]` | Resolves into the duration before the domain expires (valid units are "s", "m", "h".) | `24h`, `48h`, `1234h56m78s` |
| `[DNS_RCODE]` | Resolves into the DNS status of the response | `NOERROR` |
#### Functions
| Function | Description | Example |
|:---------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------|
| `len` | If the given path leads to an array, returns its length. Otherwise, the JSON at the given path is minified and converted to a string, and the resulting number of characters is returned. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8` |
| `has` | Returns `true` or `false` based on whether a given path is valid. Works only with the `[BODY]` placeholder. | `has([BODY].errors) == false` |
| `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)` |
| `any` | Specifies that any one of the values passed as parameters is a valid value. Works only with `==` and `!=`. | `[BODY].ip == any(127.0.0.1, ::1)` |
> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.
### Web
Allows you to configure how and where the dashboard is being served.
| Parameter | Description | Default |
|:---------------------------|:--------------------------------------------------------------------------------------------|:----------|
| `web` | Web configuration | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `""` |
| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `""` |
### UI
Allows you to configure the application wide defaults for the dashboard's UI. Some of these parameters can be overridden locally by users using the local storage of their browser.
| Parameter | Description | Default |
|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------|
| `ui` | UI configuration | `{}` |
| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` |
| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. |
| `ui.dashboard-heading` | Dashboard title between header and endpoints | `Health Dashboard` |
| `ui.dashboard-subheading` | Dashboard description between header and endpoints | `Monitor the health of your endpoints in real-time` |
| `ui.header` | Header at the top of the dashboard. | `Gatus` |
| `ui.logo` | URL to the logo to display. | `""` |
| `ui.link` | Link to open when the logo is clicked. | `""` |
| `ui.favicon.default` | Favourite default icon to display in web browser tab or address bar. | `/favicon.ico` |
| `ui.favicon.size16x16` | Favourite icon to display in web browser for 16x16 size. | `/favicon-16x16.png` |
| `ui.favicon.size32x32` | Favourite icon to display in web browser for 32x32 size. | `/favicon-32x32.png` |
| `ui.buttons` | List of buttons to display below the header. | `[]` |
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
| `ui.custom-css` | Custom CSS | `""` |
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
| `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` |
| `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` |
### Announcements
System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information. You can use markdown to format your announcements.
This is essentially what some status page calls "incident communications".
| Parameter | Description | Default |
|:----------------------------|:-------------------------------------------------------------------------------------------------------------------------|:---------|
| `announcements` | List of announcements to display | `[]` |
| `announcements[].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)
- **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)
Example Configuration:
```yaml
announcements:
- timestamp: 2025-11-07T14:00:00Z
type: outage
message: "Scheduled maintenance on database servers from 14:00 to 16:00 UTC"
- timestamp: 2025-11-07T16:15:00Z
type: operational
message: "Database maintenance completed successfully. All systems operational."
- timestamp: 2025-11-07T12:00:00Z
type: information
message: "New monitoring dashboard features will be deployed next week"
- timestamp: 2025-11-06T09:00:00Z
type: warning
message: "Elevated API response times observed for US customers"
archived: true
```
If at least one announcement is archived, a **Past Announcements** section will be rendered at the bottom of the status page:

### Storage
| Parameter | Description | Default |
|:------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
| `storage` | Storage configuration | `{}` |
| `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
| `storage.caching` | Whether to use write-through caching. Improves loading time for large dashboards. Only supported if `storage.type` is `sqlite` or `postgres` | `false` |
| `storage.maximum-number-of-results` | The maximum number of results that an endpoint can have | `100` |
| `storage.maximum-number-of-events` | The maximum number of events that an endpoint can have | `50` |
The results for each endpoint health check as well as the data for uptime and the past events must be persisted
so that they can be displayed on the dashboard. These parameters allow you to configure the storage in question.
- If `storage.type` is `memory` (default):
```yaml
# Note that this is the default value, and you can omit the storage configuration altogether to achieve the same result.
# Because the data is stored in memory, the data will not survive a restart.
storage:
type: memory
maximum-number-of-results: 200
maximum-number-of-events: 5
```
- If `storage.type` is `sqlite`, `storage.path` must not be blank:
```yaml
storage:
type: sqlite
path: data.db
```
See [examples/docker-compose-sqlite-storage](.examples/docker-compose-sqlite-storage) for an example.
- If `storage.type` is `postgres`, `storage.path` must be the connection URL:
```yaml
storage:
type: postgres
path: "postgres://user:password@127.0.0.1:5432/gatus?sslmode=disable"
```
See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres-storage) for an example.
### Client configuration
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"` |
| `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
> in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything.
This default configuration is as follows:
```yaml
client:
insecure: false
ignore-redirect: false
timeout: 10s
```
Note that this configuration is only available under `endpoints[]`, `alerting.mattermost` and `alerting.custom`.
Here's an example with the client configuration under `endpoints[]`:
```yaml
endpoints:
- name: website
url: "https://twin.sh/health"
client:
insecure: false
ignore-redirect: false
timeout: 10s
conditions:
- "[STATUS] == 200"
```
This example shows how you can specify a custom DNS resolver:
```yaml
endpoints:
- name: with-custom-dns-resolver
url: "https://your.health.api/health"
client:
dns-resolver: "tcp://8.8.8.8:53"
conditions:
- "[STATUS] == 200"
```
This example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`:
```yaml
endpoints:
- name: with-custom-oauth2
url: "https://your.health.api/health"
client:
oauth2:
token-url: https://your-token-server/token
client-id: 00000000-0000-0000-0000-000000000000
client-secret: your-client-secret
scopes: ['https://your.health.api/.default']
conditions:
- "[STATUS] == 200"
```
This example shows how you can use the `client.identity-aware-proxy` configuration to query a backend API with `Bearer token` using Google Identity-Aware-Proxy:
```yaml
endpoints:
- name: with-custom-iap
url: "https://my.iap.protected.app/health"
client:
identity-aware-proxy:
audience: "XXXXXXXX-XXXXXXXXXXXX.apps.googleusercontent.com"
conditions:
- "[STATUS] == 200"
```
> 📝 Note that Gatus will use the [gcloud default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) within its environment to generate the token.
This example shows you how you can use the `client.tls` configuration to perform an mTLS query to a backend API:
```yaml
endpoints:
- name: website
url: "https://your.mtls.protected.app/health"
client:
tls:
certificate-file: /path/to/user_cert.pem
private-key-file: /path/to/user_key.pem
renegotiation: once
conditions:
- "[STATUS] == 200"
```
> 📝 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.` | Configuration for a named SSH tunnel | `{}` |
| `tunneling..type` | Type of tunnel (currently only `SSH` is supported) | Required `""` |
| `tunneling..host` | SSH server hostname or IP address | Required `""` |
| `tunneling..port` | SSH server port | `22` |
| `tunneling..username` | SSH username | Required `""` |
| `tunneling..password` | SSH password (use either this or private-key) | `""` |
| `tunneling..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. 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
endpoints:
- name: example
url: "https://example.org"
conditions:
- "[STATUS] == 200"
alerts:
- type: slack
description: "healthcheck failed"
send-on-resolved: true
```
You can also override global provider configuration by using `alerts[].provider-override`, like so:
```yaml
endpoints:
- name: example
url: "https://example.org"
conditions:
- "[STATUS] == 200"
alerts:
- type: slack
provider-override:
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
```
> 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be
> ignored.
| Parameter | Description | Default |
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
| `alerting.awsses` | Configuration for alerts of type `awsses`. See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
| `alerting.clickup` | Configuration for alerts of type `clickup`. See [Configuring ClickUp alerts](#configuring-clickup-alerts). | `{}` |
| `alerting.custom` | Configuration for custom actions on failure or alerts. See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
| `alerting.datadog` | Configuration for alerts of type `datadog`. See [Configuring Datadog alerts](#configuring-datadog-alerts). | `{}` |
| `alerting.discord` | Configuration for alerts of type `discord`. See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.email` | Configuration for alerts of type `email`. See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
| `alerting.gitea` | Configuration for alerts of type `gitea`. See [Configuring Gitea alerts](#configuring-gitea-alerts). | `{}` |
| `alerting.github` | Configuration for alerts of type `github`. See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.gotify` | Configuration for alerts of type `gotify`. See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
| `alerting.homeassistant` | Configuration for alerts of type `homeassistant`. See [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts). | `{}` |
| `alerting.ifttt` | Configuration for alerts of type `ifttt`. See [Configuring IFTTT alerts](#configuring-ifttt-alerts). | `{}` |
| `alerting.ilert` | Configuration for alerts of type `ilert`. See [Configuring ilert alerts](#configuring-ilert-alerts). | `{}` |
| `alerting.incident-io` | Configuration for alerts of type `incident-io`. See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` |
| `alerting.line` | Configuration for alerts of type `line`. See [Configuring Line alerts](#configuring-line-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.n8n` | Configuration for alerts of type `n8n`. See [Configuring n8n alerts](#configuring-n8n-alerts). | `{}` |
| `alerting.newrelic` | Configuration for alerts of type `newrelic`. See [Configuring New Relic alerts](#configuring-new-relic-alerts). | `{}` |
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.plivo` | Configuration for alerts of type `plivo`. See [Configuring Plivo alerts](#configuring-plivo-alerts). | `{}` |
| `alerting.pushover` | Configuration for alerts of type `pushover`. See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
| `alerting.rocketchat` | Configuration for alerts of type `rocketchat`. See [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts). | `{}` |
| `alerting.sendgrid` | Configuration for alerts of type `sendgrid`. See [Configuring SendGrid alerts](#configuring-sendgrid-alerts). | `{}` |
| `alerting.signal` | Configuration for alerts of type `signal`. See [Configuring Signal alerts](#configuring-signal-alerts). | `{}` |
| `alerting.signl4` | Configuration for alerts of type `signl4`. See [Configuring SIGNL4 alerts](#configuring-signl4-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.splunk` | Configuration for alerts of type `splunk`. See [Configuring Splunk alerts](#configuring-splunk-alerts). | `{}` |
| `alerting.squadcast` | Configuration for alerts of type `squadcast`. See [Configuring Squadcast alerts](#configuring-squadcast-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)* See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`. See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
| `alerting.vonage` | Configuration for alerts of type `vonage`. See [Configuring Vonage alerts](#configuring-vonage-alerts). | `{}` |
| `alerting.webex` | Configuration for alerts of type `webex`. See [Configuring Webex alerts](#configuring-webex-alerts). | `{}` |
| `alerting.zapier` | Configuration for alerts of type `zapier`. See [Configuring Zapier alerts](#configuring-zapier-alerts). | `{}` |
| `alerting.zulip` | Configuration for alerts of type `zulip`. See [Configuring Zulip alerts](#configuring-zulip-alerts). | `{}` |
#### Configuring AWS SES alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.aws-ses` | Settings for alerts of type `aws-ses` | `{}` |
| `alerting.aws-ses.access-key-id` | AWS Access Key ID | Optional `""` |
| `alerting.aws-ses.secret-access-key` | AWS Secret Access Key | Optional `""` |
| `alerting.aws-ses.region` | AWS Region | Required `""` |
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
| `alerting.aws-ses.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.aws-ses.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.aws-ses.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.aws-ses.overrides[].*` | See `alerting.aws-ses.*` parameters | `{}` |
```yaml
alerting:
aws-ses:
access-key-id: "..."
secret-access-key: "..."
region: "us-east-1"
from: "status@example.com"
to: "user@example.com"
endpoints:
- name: website
interval: 30s
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: aws-ses
failure-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
If the `access-key-id` and `secret-access-key` are not defined Gatus will fall back to IAM authentication.
Make sure you have the ability to use `ses:SendEmail`.
#### Configuring ClickUp alerts
| Parameter | Description | Default |
| :--------------------------------- | :----------------------------------------------------------------------------------------- | :------------ |
| `alerting.clickup` | Configuration for alerts of type `clickup` | `{}` |
| `alerting.clickup.list-id` | ClickUp List ID where tasks will be created | Required `""` |
| `alerting.clickup.token` | ClickUp API token | Required `""` |
| `alerting.clickup.api-url` | Custom API URL | `https://api.clickup.com/api/v2` |
| `alerting.clickup.assignees` | List of user IDs to assign tasks to | `[]` |
| `alerting.clickup.status` | Initial status for created tasks | `""` |
| `alerting.clickup.priority` | Priority level: `urgent`, `high`, `normal`, `low`, or `none` | `normal` |
| `alerting.clickup.notify-all` | Whether to notify all assignees when task is created | `true` |
| `alerting.clickup.name` | Custom task name template (supports placeholders) | `Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]` |
| `alerting.clickup.content` | Custom task content template (supports placeholders) | `Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]` |
| `alerting.clickup.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.clickup.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.clickup.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.clickup.overrides[].*` | See `alerting.clickup.*` parameters | `{}` |
The ClickUp alerting provider creates tasks in a ClickUp list when alerts are triggered. If `send-on-resolved` is set to `true` on the endpoint alert, the task will be automatically closed when the alert is resolved.
The following placeholders are supported in `name` and `content`:
- `[ENDPOINT_GROUP]` - Resolved from `endpoints[].group`
- `[ENDPOINT_NAME]` - Resolved from `endpoints[].name`
- `[ALERT_DESCRIPTION]` - Resolved from `endpoints[].alerts[].description`
- `[RESULT_ERRORS]` - Resolved from the health evaluation errors
```yaml
alerting:
clickup:
list-id: "123456789"
token: "pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
assignees:
- "12345"
- "67890"
status: "in progress"
priority: high
name: "Health Check Alert: [ENDPOINT_GROUP] - [ENDPOINT_NAME]"
content: "Alert triggered for [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: clickup
send-on-resolved: true
```
To get your ClickUp API token follow: [Generate or regenerate a Personal API Token](https://developer.clickup.com/docs/authentication#:~:text=the%20API%20docs.-,Generate%20or%20regenerate%20a%20Personal%20API%20Token,-Log%20in%20to)
To find your List ID:
1. Open the ClickUp list where you want tasks to be created
2. The List ID is in the URL: `https://app.clickup.com/{workspace_id}/v/l/li/{list_id}`
To find Assignee IDs:
1. Go to `https://app.clickup.com/{workspace_id}/teams-pulse/teams/people`
2. Hover over a team member
3. Click the 3 dots (overflow menu)
3. Click `Copy member ID`
#### Configuring Datadog alerts
> ⚠️ **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.datadog` | Configuration for alerts of type `datadog` | `{}` |
| `alerting.datadog.api-key` | Datadog API key | Required `""` |
| `alerting.datadog.site` | Datadog site (e.g., datadoghq.com, datadoghq.eu) | `"datadoghq.com"` |
| `alerting.datadog.tags` | Additional tags to include | `[]` |
| `alerting.datadog.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.datadog.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.datadog.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.datadog.overrides[].*` | See `alerting.datadog.*` parameters | `{}` |
```yaml
alerting:
datadog:
api-key: "YOUR_API_KEY"
site: "datadoghq.com" # or datadoghq.eu for EU region
tags:
- "environment:production"
- "team:platform"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: datadog
send-on-resolved: true
```
#### Configuring Discord alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
| `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. 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 | `""` |
| `alerting.discord.overrides[].*` | See `alerting.discord.*` parameters | `{}` |
```yaml
alerting:
discord:
webhook-url: "https://discord.com/api/webhooks/**********/**********"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: discord
description: "healthcheck failed"
send-on-resolved: true
```
#### Configuring Email alerts
| Parameter | Description | Default |
|:-----------------------------------|:----------------------------------------------------------------------------------------------|:--------------|
| `alerting.email` | Configuration for alerts of type `email` | `{}` |
| `alerting.email.from` | Email used to send the alert | Required `""` |
| `alerting.email.username` | Username of the SMTP server used to send the alert. If empty, uses `alerting.email.from`. | `""` |
| `alerting.email.password` | Password of the SMTP server used to send the alert. If empty, no authentication is performed. | `""` |
| `alerting.email.host` | Host of the mail server (e.g. `smtp.gmail.com`) | Required `""` |
| `alerting.email.port` | Port the mail server is listening to (e.g. `587`) | Required `0` |
| `alerting.email.to` | Email(s) to send the alerts to | Required `""` |
| `alerting.email.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.email.client.insecure` | Whether to skip TLS verification | `false` |
| `alerting.email.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.email.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.email.overrides[].*` | See `alerting.email.*` parameters | `{}` |
```yaml
alerting:
email:
from: "from@example.com"
username: "from@example.com"
password: "hunter2"
host: "mail.example.com"
port: 587
to: "recipient1@example.com,recipient2@example.com"
client:
insecure: false
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "core"
to: "recipient3@example.com,recipient4@example.com"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: email
description: "healthcheck failed"
send-on-resolved: true
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: email
description: "healthcheck failed"
send-on-resolved: true
```
> ⚠ Some mail servers are painfully slow.
#### Configuring Gitea alerts
| Parameter | Description | Default |
|:--------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|
| `alerting.gitea` | Configuration for alerts of type `gitea` | `{}` |
| `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`) | Required `""` |
| `alerting.gitea.token` | Personal access token to use for authentication. Must have at least RW on issues and RO on metadata. | Required `""` |
| `alerting.gitea.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert). | N/A |
The Gitea alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display
name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically
closed when the alert is resolved.
```yaml
alerting:
gitea:
repository-url: "https://gitea.com/TwiN/test"
token: "349d63f16......"
endpoints:
- name: example
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 75"
alerts:
- type: gitea
failure-threshold: 2
success-threshold: 3
send-on-resolved: true
description: "Everything's burning AAAAAHHHHHHHHHHHHHHH"
```

#### Configuring GitHub alerts
| Parameter | Description | Default |
|:---------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|
| `alerting.github` | Configuration for alerts of type `github` | `{}` |
| `alerting.github.repository-url` | GitHub repository URL (e.g. `https://github.com/TwiN/example`) | Required `""` |
| `alerting.github.token` | Personal access token to use for authentication. Must have at least RW on issues and RO on metadata. | Required `""` |
| `alerting.github.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert). | N/A |
The GitHub alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display
name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically
closed when the alert is resolved.
```yaml
alerting:
github:
repository-url: "https://github.com/TwiN/test"
token: "github_pat_12345..."
endpoints:
- name: example
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 75"
alerts:
- type: github
failure-threshold: 2
success-threshold: 3
send-on-resolved: true
description: "Everything's burning AAAAAHHHHHHHHHHHHHHH"
```

#### Configuring GitLab alerts
| Parameter | Description | Default |
|:------------------------------------|:--------------------------------------------------------------------------------------------------------------------|:--------------|
| `alerting.gitlab` | Configuration for alerts of type `gitlab` | `{}` |
| `alerting.gitlab.webhook-url` | GitLab alert webhook URL (e.g. `https://gitlab.com/yourusername/example/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json`) | Required `""` |
| `alerting.gitlab.authorization-key` | GitLab alert authorization key. | Required `""` |
| `alerting.gitlab.severity` | Override default severity (critical), can be one of `critical, high, medium, low, info, unknown` | `""` |
| `alerting.gitlab.monitoring-tool` | Override the monitoring tool name (gatus) | `"gatus"` |
| `alerting.gitlab.environment-name` | Set gitlab environment's name. Required to display alerts on a dashboard. | `""` |
| `alerting.gitlab.service` | Override endpoint display name | `""` |
| `alerting.gitlab.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert). | N/A |
The GitLab alerting provider creates an alert prefixed with `alert(gatus):` and suffixed with the endpoint's display
name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the alert will be automatically
closed when the alert is resolved. See
https://docs.gitlab.com/ee/operations/incident_management/integrations.html#configuration to configure the endpoint.
```yaml
alerting:
gitlab:
webhook-url: "https://gitlab.com/hlidotbe/example/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json"
authorization-key: "12345"
endpoints:
- name: example
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 75"
alerts:
- type: gitlab
failure-threshold: 2
success-threshold: 3
send-on-resolved: true
description: "Everything's burning AAAAAHHHHHHHHHHHHHHH"
```

#### Configuring Google Chat alerts
| Parameter | Description | Default |
|:----------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
| `alerting.googlechat.client` | Client configuration. See [Client configuration](#client-configuration). | `{}` |
| `alerting.googlechat.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.googlechat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.googlechat.overrides[].*` | See `alerting.googlechat.*` parameters | `{}` |
```yaml
alerting:
googlechat:
webhook-url: "https://chat.googleapis.com/v1/spaces/*******/messages?key=**********&token=********"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: googlechat
description: "healthcheck failed"
send-on-resolved: true
```
#### Configuring Gotify alerts
| Parameter | Description | Default |
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:----------------------|
| `alerting.gotify` | Configuration for alerts of type `gotify` | `{}` |
| `alerting.gotify.server-url` | Gotify server URL | Required `""` |
| `alerting.gotify.token` | Token that is used for authentication. | Required `""` |
| `alerting.gotify.priority` | Priority of the alert according to Gotify standards. | `5` |
| `alerting.gotify.title` | Title of the notification | `"Gatus: "` |
| `alerting.gotify.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert). | N/A |
```yaml
alerting:
gotify:
server-url: "https://gotify.example"
token: "**************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: gotify
description: "healthcheck failed"
send-on-resolved: true
```
Here's an example of what the notifications look like:

#### Configuring HomeAssistant alerts
| Parameter | Description | Default Value |
|:-------------------------------------------|:---------------------------------------------------------------------------------------|:--------------|
| `alerting.homeassistant.url` | HomeAssistant instance URL | Required `""` |
| `alerting.homeassistant.token` | Long-lived access token from HomeAssistant | Required `""` |
| `alerting.homeassistant.default-alert` | Default alert configuration to use for endpoints with an alert of the appropriate type | `{}` |
| `alerting.homeassistant.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.homeassistant.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.homeassistant.overrides[].*` | See `alerting.homeassistant.*` parameters | `{}` |
```yaml
alerting:
homeassistant:
url: "http://homeassistant:8123" # URL of your HomeAssistant instance
token: "YOUR_LONG_LIVED_ACCESS_TOKEN" # Long-lived access token from HomeAssistant
endpoints:
- name: my-service
url: "https://my-service.com"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: homeassistant
enabled: true
send-on-resolved: true
description: "My service health check"
failure-threshold: 3
success-threshold: 2
```
The alerts will be sent as events to HomeAssistant with the event type `gatus_alert`. The event data includes:
- `status`: "triggered" or "resolved"
- `endpoint`: The name of the monitored endpoint
- `description`: The alert description if provided
- `conditions`: List of conditions and their results
- `failure_count`: Number of consecutive failures (when triggered)
- `success_count`: Number of consecutive successes (when resolved)
You can use these events in HomeAssistant automations to:
- Send notifications
- Control devices
- Trigger scenes
- Log to history
- And more
Example HomeAssistant automation:
```yaml
automation:
- alias: "Gatus Alert Handler"
trigger:
platform: event
event_type: gatus_alert
action:
- service: notify.notify
data_template:
title: "Gatus Alert: {{ trigger.event.data.event_data.endpoint }}"
message: >
Status: {{ trigger.event.data.event_data.status }}
{% if trigger.event.data.event_data.description %}
Description: {{ trigger.event.data.event_data.description }}
{% endif %}
{% for condition in trigger.event.data.event_data.conditions %}
{{ '✅' if condition.success else '❌' }} {{ condition.condition }}
{% endfor %}
```
To get your HomeAssistant long-lived access token:
1. Open HomeAssistant
2. Click on your profile name (bottom left)
3. Scroll down to "Long-Lived Access Tokens"
4. Click "Create Token"
5. Give it a name (e.g., "Gatus")
6. Copy the token - you'll only see it once!
#### Configuring IFTTT 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.ifttt` | Configuration for alerts of type `ifttt` | `{}` |
| `alerting.ifttt.webhook-key` | IFTTT Webhook key | Required `""` |
| `alerting.ifttt.event-name` | IFTTT event name | Required `""` |
| `alerting.ifttt.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.ifttt.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.ifttt.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.ifttt.overrides[].*` | See `alerting.ifttt.*` parameters | `{}` |
```yaml
alerting:
ifttt:
webhook-key: "YOUR_WEBHOOK_KEY"
event-name: "gatus_alert"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: ifttt
send-on-resolved: true
```
#### Configuring Ilert alerts
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.ilert` | Configuration for alerts of type `ilert` | `{}` |
| `alerting.ilert.integration-key` | ilert Alert Source integration key | `""` |
| `alerting.ilert.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.ilert.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.ilert.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.ilert.overrides[].*` | See `alerting.ilert.*` parameters | `{}` |
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
of type `ilert`, because unlike other alerts, the operation resulting from setting said
parameter to `true` will not create another alert but mark the alert as resolved on
ilert instead.
Behavior:
- By default, `alerting.ilert.integration-key` is used as the integration key
- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.ilert.overrides[].group`, the provider will use that override's integration key instead of `alerting.ilert.integration-key`'s
```yaml
alerting:
ilert:
integration-key: "********************************"
# You can also add group-specific integration keys, which will
# override the integration key above for the specified groups
overrides:
- group: "core"
integration-key: "********************************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: ilert
failure-threshold: 3
success-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring Incident.io alerts
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.incident-io` | Configuration for alerts of type `incident-io` | `{}` |
| `alerting.incident-io.url` | url to trigger an alert event. | Required `""` |
| `alerting.incident-io.auth-token` | Token that is used for authentication. | Required `""` |
| `alerting.incident-io.source-url` | Source URL | `""` |
| `alerting.incident-io.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.incident-io.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.incident-io.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.incident-io.overrides[].*` | See `alerting.incident-io.*` parameters | `{}` |
```yaml
alerting:
incident-io:
url: "*****************"
auth-token: "********************************************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: incident-io
description: "healthcheck failed"
send-on-resolved: true
```
In order to get the required alert source config id and authentication token, you must configure an HTTP alert source.
> **_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 Line alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.line` | Configuration for alerts of type `line` | `{}` |
| `alerting.line.channel-access-token` | Line Messaging API channel access token | Required `""` |
| `alerting.line.user-ids` | List of Line user IDs to send messages to (this can be user ids, room ids or group ids) | Required `[]` |
| `alerting.line.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.line.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.line.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.line.overrides[].*` | See `alerting.line.*` parameters | `{}` |
```yaml
alerting:
line:
channel-access-token: "YOUR_CHANNEL_ACCESS_TOKEN"
user-ids:
- "U1234567890abcdef" # This can be a group id, room id or user id
- "U2345678901bcdefg"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: line
send-on-resolved: true
```
#### Configuring Matrix alerts
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------|
| `alerting.matrix` | Configuration for alerts of type `matrix` | `{}` |
| `alerting.matrix.server-url` | Homeserver URL | `https://matrix-client.matrix.org` |
| `alerting.matrix.access-token` | Bot user access token (see https://webapps.stackexchange.com/q/131056) | Required `""` |
| `alerting.matrix.internal-room-id` | Internal room ID of room to send alerts to (can be found in Room Settings > Advanced) | Required `""` |
| `alerting.matrix.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.matrix.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.matrix.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.matrix.overrides[].*` | See `alerting.matrix.*` parameters | `{}` |
```yaml
alerting:
matrix:
server-url: "https://matrix-client.matrix.org"
access-token: "123456"
internal-room-id: "!example:matrix.org"
endpoints:
- name: website
interval: 5m
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: matrix
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring Mattermost alerts
| Parameter | Description | Default |
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
| `alerting.mattermost.channel` | Mattermost channel name override (optional) | `""` |
| `alerting.mattermost.client` | Client configuration. See [Client configuration](#client-configuration). | `{}` |
| `alerting.mattermost.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.mattermost.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.mattermost.overrides[].*` | See `alerting.mattermost.*` parameters | `{}` |
```yaml
alerting:
mattermost:
webhook-url: "http://**********/hooks/**********"
client:
insecure: true
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: mattermost
description: "healthcheck failed"
send-on-resolved: true
```
Here's an example of what the notifications look like:

#### Configuring Messagebird alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.messagebird` | Configuration for alerts of type `messagebird` | `{}` |
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
| `alerting.messagebird.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
Example of sending **SMS** text message alert using Messagebird:
```yaml
alerting:
messagebird:
access-key: "..."
originator: "31619191918"
recipients: "31619191919,31619191920"
endpoints:
- name: website
interval: 5m
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: messagebird
failure-threshold: 3
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring New Relic 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.newrelic` | Configuration for alerts of type `newrelic` | `{}` |
| `alerting.newrelic.api-key` | New Relic API key | Required `""` |
| `alerting.newrelic.account-id` | New Relic account ID | Required `""` |
| `alerting.newrelic.region` | Region (US or EU) | `"US"` |
| `alerting.newrelic.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.newrelic.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.newrelic.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.newrelic.overrides[].*` | See `alerting.newrelic.*` parameters | `{}` |
```yaml
alerting:
newrelic:
api-key: "YOUR_API_KEY"
account-id: "1234567"
region: "US" # or "EU" for European region
endpoints:
- name: example
url: "https://example.org"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: newrelic
send-on-resolved: true
```
#### 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. 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 |
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` |
| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` |
| `alerting.ntfy.click` | Website opened when notification is clicked | `""` |
| `alerting.ntfy.priority` | The priority of the alert | `3` |
| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` |
| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` |
| `alerting.ntfy.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.ntfy.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.ntfy.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.ntfy.overrides[].*` | See `alerting.ntfy.*` parameters | `{}` |
[ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop
and mobile notifications, making it an awesome addition to Gatus.
Example:
```yaml
alerting:
ntfy:
topic: "gatus-test-topic"
priority: 2
token: faketoken
default-alert:
failure-threshold: 3
send-on-resolved: true
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "other"
topic: "gatus-other-test-topic"
priority: 4
click: "https://example.com"
endpoints:
- name: website
interval: 5m
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: ntfy
- name: other example
group: other
interval: 30m
url: "https://example.com"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
alerts:
- type: ntfy
description: example
```
#### Configuring Opsgenie alerts
| Parameter | Description | Default |
|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------|
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie` | `{}` |
| `alerting.opsgenie.api-key` | Opsgenie API Key | Required `""` |
| `alerting.opsgenie.priority` | Priority level of the alert. | `P1` |
| `alerting.opsgenie.source` | Source field of the alert. | `gatus` |
| `alerting.opsgenie.entity-prefix` | Entity field prefix. | `gatus-` |
| `alerting.opsgenie.alias-prefix` | Alias field prefix. | `gatus-healthcheck-` |
| `alerting.opsgenie.tags` | Tags of alert. | `[]` |
| `alerting.opsgenie.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
Opsgenie provider will automatically open and close alerts.
```yaml
alerting:
opsgenie:
api-key: "00000000-0000-0000-0000-000000000000"
```
#### Configuring PagerDuty alerts
| Parameter | Description | Default |
|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
| `alerting.pagerduty.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.pagerduty.overrides[].*` | See `alerting.pagerduty.*` parameters | `{}` |
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
of type `pagerduty`, because unlike other alerts, the operation resulting from setting said
parameter to `true` will not create another incident but mark the incident as resolved on
PagerDuty instead.
Behavior:
- By default, `alerting.pagerduty.integration-key` is used as the integration key
- If the endpoint being evaluated belongs to a group (`endpoints[].group`) matching the value of `alerting.pagerduty.overrides[].group`, the provider will use that override's integration key instead of `alerting.pagerduty.integration-key`'s
```yaml
alerting:
pagerduty:
integration-key: "********************************"
# You can also add group-specific integration keys, which will
# override the integration key above for the specified groups
overrides:
- group: "core"
integration-key: "********************************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: pagerduty
failure-threshold: 3
success-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: pagerduty
failure-threshold: 3
success-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring Plivo 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.plivo` | Configuration for alerts of type `plivo` | `{}` |
| `alerting.plivo.auth-id` | Plivo Auth ID | Required `""` |
| `alerting.plivo.auth-token` | Plivo Auth Token | Required `""` |
| `alerting.plivo.from` | Phone number to send SMS from | Required `""` |
| `alerting.plivo.to` | List of phone numbers to send SMS to | Required `[]` |
| `alerting.plivo.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.plivo.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.plivo.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.plivo.overrides[].*` | See `alerting.plivo.*` parameters | `{}` |
```yaml
alerting:
plivo:
auth-id: "MAXXXXXXXXXXXXXXXXXX"
auth-token: "your-auth-token"
from: "+1234567890"
to:
- "+0987654321"
- "+1122334455"
endpoints:
- name: website
interval: 30s
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: plivo
failure-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring Pushover alerts
| Parameter | Description | Default |
|:--------------------------------------|:-------------------------------------------------------------------------------------------------------------|:----------------------|
| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` |
| `alerting.pushover.application-token` | Pushover application token | `""` |
| `alerting.pushover.user-key` | User or group key | `""` |
| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | `"Gatus: "` |
| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` |
| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency) | `0` |
| `alerting.pushover.sound` | Sound of all messages See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` |
| `alerting.pushover.ttl` | Set the Time-to-live of the message to be automatically deleted from pushover notifications | `0` |
| `alerting.pushover.device` | Device to send the message to (optional) See [devices](https://pushover.net/api#identifiers) for details | `""` (all devices) |
| `alerting.pushover.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
pushover:
application-token: "******************************"
user-key: "******************************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: pushover
failure-threshold: 3
success-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring Rocket.Chat 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.rocketchat` | Configuration for alerts of type `rocketchat` | `{}` |
| `alerting.rocketchat.webhook-url` | Rocket.Chat incoming webhook URL | Required `""` |
| `alerting.rocketchat.channel` | Optional channel override | `""` |
| `alerting.rocketchat.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.rocketchat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.rocketchat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.rocketchat.overrides[].*` | See `alerting.rocketchat.*` parameters | `{}` |
```yaml
alerting:
rocketchat:
webhook-url: "https://your-rocketchat.com/hooks/YOUR_WEBHOOK_ID/YOUR_TOKEN"
channel: "#alerts" # Optional
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: rocketchat
send-on-resolved: true
```
#### 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. 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.
| Parameter | Description | Default |
|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.signal` | Configuration for alerts of type `signal` | `{}` |
| `alerting.signal.api-url` | Signal API URL (e.g., signal-cli-rest-api instance) | Required `""` |
| `alerting.signal.number` | Sender phone number | Required `""` |
| `alerting.signal.recipients` | List of recipient phone numbers | Required `[]` |
| `alerting.signal.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.signal.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.signal.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.signal.overrides[].*` | See `alerting.signal.*` parameters | `{}` |
```yaml
alerting:
signal:
api-url: "http://localhost:8080"
number: "+1234567890"
recipients:
- "+0987654321"
- "+1122334455"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: signal
send-on-resolved: true
```
#### Configuring SIGNL4 alerts
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 |
|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.signl4` | Configuration for alerts of type `signl4` | `{}` |
| `alerting.signl4.team-secret` | SIGNL4 team secret (part of webhook URL) | Required `""` |
| `alerting.signl4.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.signl4.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.signl4.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.signl4.overrides[].*` | See `alerting.signl4.*` parameters | `{}` |
```yaml
alerting:
signl4:
team-secret: "your-team-secret-here"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: signl4
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.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
| `alerting.slack.default-alert` | Default alert configuration. 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:
slack:
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: slack
description: "healthcheck failed 3 times in a row"
send-on-resolved: true
- type: slack
failure-threshold: 5
description: "healthcheck failed 5 times in a row"
send-on-resolved: true
```
Here's an example of what the notifications look like:

#### Configuring Splunk alerts
| Parameter | Description | Default |
|:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------|
| `alerting.splunk` | Configuration for alerts of type `splunk` | `{}` |
| `alerting.splunk.hec-url` | Splunk HEC (HTTP Event Collector) URL | Required `""` |
| `alerting.splunk.hec-token` | Splunk HEC token | Required `""` |
| `alerting.splunk.source` | Event source | `"gatus"` |
| `alerting.splunk.sourcetype` | Event source type | `"gatus:alert"` |
| `alerting.splunk.index` | Splunk index | `""` |
| `alerting.splunk.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.splunk.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.splunk.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.splunk.overrides[].*` | See `alerting.splunk.*` parameters | `{}` |
```yaml
alerting:
splunk:
hec-url: "https://splunk.example.com:8088"
hec-token: "YOUR_HEC_TOKEN"
index: "main" # Optional
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: splunk
send-on-resolved: true
```
#### Configuring Squadcast 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.squadcast` | Configuration for alerts of type `squadcast` | `{}` |
| `alerting.squadcast.webhook-url` | Squadcast webhook URL | Required `""` |
| `alerting.squadcast.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.squadcast.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.squadcast.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.squadcast.overrides[].*` | See `alerting.squadcast.*` parameters | `{}` |
```yaml
alerting:
squadcast:
webhook-url: "https://api.squadcast.com/v3/incidents/api/YOUR_API_KEY"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: squadcast
send-on-resolved: true
```
#### Configuring Teams alerts *(Deprecated)*
> [!CAUTION]
> **Deprecated:** Office 365 Connectors within Microsoft Teams are being retired ([Source: Microsoft DevBlog](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)).
> Existing connectors will continue to work until December 2025. The new [Teams Workflow Alerts](#configuring-teams-workflow-alerts) should be used with Microsoft Workflows instead of this legacy configuration.
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------------|
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams.title` | Title of the notification | `"🚨 Gatus"` |
| `alerting.teams.client.insecure` | Whether to skip TLS verification | `false` |
| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams.overrides[].*` | See `alerting.teams.*` parameters | `{}` |
```yaml
alerting:
teams:
webhook-url: "https://********.webhook.office.com/webhookb2/************"
client:
insecure: false
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "core"
webhook-url: "https://********.webhook.office.com/webhookb3/************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: teams
description: "healthcheck failed"
send-on-resolved: true
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: teams
description: "healthcheck failed"
send-on-resolved: true
```
Here's an example of what the notifications look like:

#### Configuring Teams Workflow alerts
> [!NOTE]
> This alert is compatible with Workflows for Microsoft Teams. To setup the workflow and get the webhook URL, follow the [Microsoft Documentation](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498).
| Parameter | Description | Default |
|:---------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------|
| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams-workflows.title` | Title of the notification | `"⛑ Gatus"` |
| `alerting.teams-workflows.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.teams-workflows.overrides[].*` | See `alerting.teams-workflows.*` parameters | `{}` |
```yaml
alerting:
teams-workflows:
webhook-url: "https://********.webhook.office.com/webhookb2/************"
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "core"
webhook-url: "https://********.webhook.office.com/webhookb3/************"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: teams-workflows
description: "healthcheck failed"
send-on-resolved: true
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: teams-workflows
description: "healthcheck failed"
send-on-resolved: true
```
Here's an example of what the notifications look like:

#### Configuring Telegram alerts
| Parameter | Description | Default |
|:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram Chat ID | Required `""` |
| `alerting.telegram.topic-id` | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API | `""` |
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
| `alerting.telegram.client` | Client configuration. See [Client configuration](#client-configuration). | `{}` |
| `alerting.telegram.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.telegram.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.telegram.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.telegram.overrides[].*` | See `alerting.telegram.*` parameters | `{}` |
```yaml
alerting:
telegram:
token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
id: "0123456789"
topic-id: "7"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
alerts:
- type: telegram
send-on-resolved: true
```
Here's an example of what the notifications look like:

#### Configuring Twilio alerts
| Parameter | Description | Default |
|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.twilio` | Settings for alerts of type `twilio` | `{}` |
| `alerting.twilio.sid` | Twilio account SID | Required `""` |
| `alerting.twilio.token` | Twilio auth token | Required `""` |
| `alerting.twilio.from` | Number to send Twilio alerts from | Required `""` |
| `alerting.twilio.to` | Number to send twilio alerts to | Required `""` |
| `alerting.twilio.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
Custom message templates are supported via the following additional parameters:
| Parameter | Description | Default |
|:----------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
| `alerting.twilio.text-twilio-triggered` | Custom message template for triggered alerts. Supports `[ENDPOINT]`, `[ALERT_DESCRIPTION]` | `""` |
| `alerting.twilio.text-twilio-resolved` | Custom message template for resolved alerts. Supports `[ENDPOINT]`, `[ALERT_DESCRIPTION]` | `""` |
```yaml
alerting:
twilio:
sid: "..."
token: "..."
from: "+1-234-567-8901"
to: "+1-234-567-8901"
# Custom message templates using placeholders (optional)
# Supports both old format {endpoint}/{description} and new format [ENDPOINT]/[ALERT_DESCRIPTION]
text-twilio-triggered: "🚨 ALERT: [ENDPOINT] is down! [ALERT_DESCRIPTION]"
text-twilio-resolved: "✅ RESOLVED: [ENDPOINT] is back up! [ALERT_DESCRIPTION]"
endpoints:
- name: website
interval: 30s
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: twilio
failure-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring Vonage 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.vonage` | Configuration for alerts of type `vonage` | `{}` |
| `alerting.vonage.api-key` | Vonage API key | Required `""` |
| `alerting.vonage.api-secret` | Vonage API secret | Required `""` |
| `alerting.vonage.from` | Sender name or phone number | Required `""` |
| `alerting.vonage.to` | Recipient phone number | Required `""` |
| `alerting.vonage.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.vonage.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.vonage.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.vonage.overrides[].*` | See `alerting.vonage.*` parameters | `{}` |
```yaml
alerting:
vonage:
api-key: "YOUR_API_KEY"
api-secret: "YOUR_API_SECRET"
from: "Gatus"
to: "+1234567890"
```
Example of sending alerts to Vonage:
```yaml
endpoints:
- name: website
url: "https://example.org"
alerts:
- type: vonage
failure-threshold: 5
send-on-resolved: true
description: "healthcheck failed"
```
#### Configuring Webex 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.webex` | Configuration for alerts of type `webex` | `{}` |
| `alerting.webex.webhook-url` | Webex Teams webhook URL | Required `""` |
| `alerting.webex.default-alert` | Default alert configuration. 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:
webex:
webhook-url: "https://webexapis.com/v1/webhooks/incoming/YOUR_WEBHOOK_ID"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: webex
send-on-resolved: true
```
#### Configuring Zapier 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.zapier` | Configuration for alerts of type `zapier` | `{}` |
| `alerting.zapier.webhook-url` | Zapier webhook URL | Required `""` |
| `alerting.zapier.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.zapier.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.zapier.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.zapier.overrides[].*` | See `alerting.zapier.*` parameters | `{}` |
```yaml
alerting:
zapier:
webhook-url: "https://hooks.zapier.com/hooks/catch/YOUR_WEBHOOK_ID/"
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
alerts:
- type: zapier
send-on-resolved: true
```
#### Configuring Zulip alerts
| Parameter | Description | Default |
|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|
| `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 `""` |
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
| `alerting.zulip.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.zulip.overrides[].*` | See `alerting.zulip.*` parameters | `{}` |
```yaml
alerting:
zulip:
bot-email: gatus-bot@some.zulip.org
bot-api-key: "********************************"
domain: some.zulip.org
channel-id: 123456
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: zulip
description: "healthcheck failed"
send-on-resolved: true
```
#### Configuring custom alerts
| Parameter | Description | Default |
|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` |
| `alerting.custom.url` | Custom alerting request url | Required `""` |
| `alerting.custom.method` | Request method | `GET` |
| `alerting.custom.body` | Custom alerting request body. | `""` |
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
| `alerting.custom.client` | Client configuration. See [Client configuration](#client-configuration). | `{}` |
| `alerting.custom.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.custom.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.custom.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.custom.overrides[].*` | See `alerting.custom.*` parameters | `{}` |
While they're called alerts, you can use this feature to call anything.
For instance, you could automate rollbacks by having an application that keeps tracks of new deployments, and by
leveraging Gatus, you could have Gatus call that application endpoint when an endpoint starts failing. Your application
would then check if the endpoint that started failing was part of the recently deployed application, and if it was,
then automatically roll it back.
Furthermore, you may use the following placeholders in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`):
- `[ALERT_DESCRIPTION]` (resolved from `endpoints[].alerts[].description`)
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
- `[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
(details at the end of this section).
For all intents and purposes, we'll configure the custom alert with a Slack webhook, but you can call anything you want.
```yaml
alerting:
custom:
url: "https://hooks.slack.com/services/**********/**********/**********"
method: "POST"
body: |
{
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
}
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: custom
failure-threshold: 10
success-threshold: 3
send-on-resolved: true
description: "health check failed"
```
Note that you can customize the resolved values for the `[ALERT_TRIGGERED_OR_RESOLVED]` placeholder like so:
```yaml
alerting:
custom:
placeholders:
ALERT_TRIGGERED_OR_RESOLVED:
TRIGGERED: "partial_outage"
RESOLVED: "operational"
```
As a result, the `[ALERT_TRIGGERED_OR_RESOLVED]` in the body of first example of this section would be replaced by
`partial_outage` when an alert is triggered and `operational` when an alert is resolved.
#### Setting a default alert
| Parameter | Description | Default |
|:---------------------------------------------|:------------------------------------------------------------------------------|:--------|
| `alerting.*.default-alert.enabled` | Whether to enable the alert | N/A |
| `alerting.*.default-alert.failure-threshold` | Number of failures in a row needed before triggering the alert | N/A |
| `alerting.*.default-alert.success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved | N/A |
| `alerting.*.default-alert.send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved | N/A |
| `alerting.*.default-alert.description` | Description of the alert. Will be included in the alert sent | N/A |
> ⚠ You must still specify the `type` of the alert in the endpoint configuration even if you set the default alert of a provider.
While you can specify the alert configuration directly in the endpoint definition, it's tedious and may lead to a very
long configuration file.
To avoid such problem, you can use the `default-alert` parameter present in each provider configuration:
```yaml
alerting:
slack:
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
default-alert:
description: "health check failed"
send-on-resolved: true
failure-threshold: 5
success-threshold: 5
```
As a result, your Gatus configuration looks a lot tidier:
```yaml
endpoints:
- name: example
url: "https://example.org"
conditions:
- "[STATUS] == 200"
alerts:
- type: slack
- name: other-example
url: "https://example.com"
conditions:
- "[STATUS] == 200"
alerts:
- type: slack
```
It also allows you to do things like this:
```yaml
endpoints:
- name: example
url: "https://example.org"
conditions:
- "[STATUS] == 200"
alerts:
- type: slack
failure-threshold: 5
- type: slack
failure-threshold: 10
- type: slack
failure-threshold: 15
```
Of course, you can also mix alert types:
```yaml
alerting:
slack:
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
default-alert:
failure-threshold: 3
pagerduty:
integration-key: "********************************"
default-alert:
failure-threshold: 5
endpoints:
- name: endpoint-1
url: "https://example.org"
conditions:
- "[STATUS] == 200"
alerts:
- type: slack
- type: pagerduty
- name: endpoint-2
url: "https://example.org"
conditions:
- "[STATUS] == 200"
alerts:
- type: slack
- type: pagerduty
```
### Maintenance
If you have maintenance windows, you may not want to be annoyed by alerts.
To do that, you'll have to use the maintenance configuration:
| Parameter | Description | Default |
|:-----------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
| `maintenance.timezone` | Timezone of the maintenance window format (e.g. `Europe/Amsterdam`). See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more info | `UTC` |
| `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`). If left empty, the maintenance window applies every day | `[]` |
Here's an example:
```yaml
maintenance:
start: 23:00
duration: 1h
timezone: "Europe/Amsterdam"
every: [Monday, Thursday]
```
Note that you can also specify each day on separate lines:
```yaml
maintenance:
start: 23:00
duration: 1h
timezone: "Europe/Amsterdam"
every:
- Monday
- Thursday
```
You can also specify maintenance windows on a per-endpoint basis:
```yaml
endpoints:
- name: endpoint-1
url: "https://example.org"
maintenance-windows:
- start: "07:30"
duration: 40m
timezone: "Europe/Berlin"
- start: "14:30"
duration: 1h
timezone: "Europe/Berlin"
```
### Security
| Parameter | Description | Default |
|:-----------------|:-----------------------------|:--------|
| `security` | Security configuration | `{}` |
| `security.basic` | HTTP Basic configuration | `{}` |
| `security.oidc` | OpenID Connect configuration | `{}` |
#### Basic Authentication
| Parameter | Description | Default |
|:----------------------------------------|:-----------------------------------------------------------------------------------|:--------------|
| `security.basic` | HTTP Basic configuration | `{}` |
| `security.basic.username` | Username for Basic authentication. | Required `""` |
| `security.basic.password-bcrypt-base64` | Password hashed with Bcrypt and then encoded with base64 for Basic authentication. | Required `""` |
The example below will require that you authenticate with the username `john.doe` and the password `hunter2`:
```yaml
security:
basic:
username: "john.doe"
password-bcrypt-base64: "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
```
> ⚠ Make sure to carefully select the cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,
> and basic auth verifies the password against the hash on every request. As of 2023-01-06, I suggest a cost of 9.
#### OIDC
| Parameter | Description | Default |
|:---------------------------------|:---------------------------------------------------------------|:--------------|
| `security.oidc` | OpenID Connect configuration | `{}` |
| `security.oidc.issuer-url` | Issuer URL | Required `""` |
| `security.oidc.redirect-url` | Redirect URL. Must end with `/authorization-code/callback` | Required `""` |
| `security.oidc.client-id` | Client id | Required `""` |
| `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:
oidc:
issuer-url: "https://example.okta.com"
redirect-url: "https://status.example.com/authorization-code/callback"
client-id: "123456789"
client-secret: "abcdefghijk"
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).
### TLS Encryption
Gatus supports basic encryption with TLS. To enable this, certificate files in PEM format have to be provided.
The example below shows an example configuration which makes gatus respond on port 4443 to HTTPS requests:
```yaml
web:
port: 4443
tls:
certificate-file: "certificate.crt"
private-key-file: "private.key"
```
### Metrics
To enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics`
endpoint on the same port your application is configured to run on (`web.port`).
| Metric name | Type | Description | Labels | Relevant endpoint types |
|:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------|
| gatus_results_total | counter | Number of results per endpoint per success state | key, group, name, type, success | All |
| gatus_results_code_total | counter | Total number of results by code | key, group, name, type, code | DNS, HTTP |
| 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.
#### Custom Labels
You can add custom labels to your endpoints’ Prometheus metrics by defining key–value pairs under the `extra-labels` field. For example:
```yaml
endpoints:
- name: front-end
group: core
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 150"
extra-labels:
environment: staging
```
### Connectivity
| Parameter | Description | Default |
|:--------------------------------|:-------------------------------------------|:--------------|
| `connectivity` | Connectivity configuration | `{}` |
| `connectivity.checker` | Connectivity checker configuration | Required `{}` |
| `connectivity.checker.target` | Host to use for validating connectivity | Required `""` |
| `connectivity.checker.interval` | Interval at which to validate connectivity | `1m` |
While Gatus is used to monitor other services, it is possible for Gatus itself to lose connectivity to the internet.
In order to prevent Gatus from reporting endpoints as unhealthy when Gatus itself is unhealthy, you may configure
Gatus to periodically check for internet connectivity.
All endpoint executions are skipped while the connectivity checker deems connectivity to be down.
```yaml
connectivity:
checker:
target: 1.1.1.1:53
interval: 60s
```
### Remote instances (EXPERIMENTAL)
This feature allows you to retrieve endpoint statuses from a remote Gatus instance.
There are two main use cases for this:
- You have multiple Gatus instances running on different machines, and you wish to visually expose the statuses through a single dashboard
- You have one or more Gatus instances that are not publicly accessible (e.g. behind a firewall), and you wish to retrieve
This is an experimental feature. It may be removed or updated in a breaking manner at any time. Furthermore,
there are known issues with this feature. If you'd like to provide some feedback, please write a comment in [#64](https://github.com/TwiN/gatus/issues/64).
Use at your own risk.
| Parameter | Description | Default |
|:-----------------------------------|:---------------------------------------------|:--------------|
| `remote` | Remote configuration | `{}` |
| `remote.instances` | List of remote instances | Required `[]` |
| `remote.instances.endpoint-prefix` | String to prefix all endpoint names with | `""` |
| `remote.instances.url` | URL from which to retrieve endpoint statuses | Required `""` |
```yaml
remote:
instances:
- endpoint-prefix: "status.example.org-"
url: "https://status.example.org/api/v1/endpoints/statuses"
```
## Deployment
Many examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus.
### Docker
To run Gatus locally with Docker:
```console
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 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 ghcr.io/twin/gatus:stable
```
To build the image locally:
```console
docker build . -t ghcr.io/twin/gatus:stable
```
### Helm Chart
[Helm](https://helm.sh) must be installed to use the chart.
Please refer to Helm's [documentation](https://helm.sh/docs/) to get started.
Once Helm is set up properly, add the repository as follows:
```console
helm repo add twin https://twin.github.io/helm-charts
helm repo update
helm install gatus twin/gatus
```
To get more details, please check [chart's configuration](https://github.com/TwiN/helm-charts/blob/master/charts/gatus/README.md).
### Terraform
#### Kubernetes
Gatus can be deployed on Kubernetes using Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).
## Running the tests
```console
go test -v ./...
```
## Using in Production
See the [Deployment](#deployment) section.
## FAQ
### Sending a GraphQL request
By setting `endpoints[].graphql` to true, the body will automatically be wrapped by the standard GraphQL `query` parameter.
For instance, the following configuration:
```yaml
endpoints:
- name: filter-users-by-gender
url: http://localhost:8080/playground
method: POST
graphql: true
body: |
{
users(gender: "female") {
id
name
gender
avatar
}
}
conditions:
- "[STATUS] == 200"
- "[BODY].data.users[0].gender == female"
```
will send a `POST` request to `http://localhost:8080/playground` with the following body:
```json
{"query":" {\n users(gender: \"female\") {\n id\n name\n gender\n avatar\n }\n }"}
```
### Recommended interval
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.
The number of concurrent evaluations is determined by the `concurrency` configuration parameter, which defaults to `3`.
You can test this yourself by running Gatus with several endpoints configured with a very short, unrealistic interval,
such as 1ms. You'll notice that the response time does not fluctuate - that is because while endpoints are evaluated on
different goroutines, there's a semaphore that controls how many endpoints/suites from running at the same time.
Unfortunately, there is a drawback. If you have a lot of endpoints, including some that are very slow or prone to timing out
(the default timeout is 10s), those slow evaluations may prevent other endpoints/suites from being evaluated.
The interval does not include the duration of the request itself, which means that if an endpoint has an interval of 30s
and the request takes 2s to complete, the timestamp between two evaluations will be 32s, not 30s.
While this does not prevent Gatus' from performing health checks on all other endpoints, it may cause Gatus to be unable
to respect the configured interval, for instance, assuming `concurrency` is set to `1`:
- Endpoint A has an interval of 5s, and times out after 10s to complete
- Endpoint B has an interval of 5s, and takes 1ms to complete
- Endpoint B will be unable to run every 5s, because endpoint A's health evaluation takes longer than its interval
To sum it up, while Gatus can handle any interval you throw at it, you're better off having slow requests with
higher interval.
As a rule of thumb, I personally set the interval for more complex health checks to `5m` (5 minutes) and
simple health checks used for alerting (PagerDuty/Twilio) to `30s`.
### Default timeouts
| Endpoint type | Timeout |
|:--------------|:--------|
| HTTP | 10s |
| TCP | 10s |
| ICMP | 10s |
To modify the timeout, see [Client configuration](#client-configuration).
### Monitoring a TCP endpoint
By prefixing `endpoints[].url` with `tcp://`, you can monitor TCP endpoints at a very basic level:
```yaml
endpoints:
- name: redis
url: "tcp://127.0.0.1:6379"
interval: 30s
conditions:
- "[CONNECTED] == true"
```
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.
This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.).
> 📝 `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's
> something at the given address listening to the given port, and that a connection to that address was successfully
> established.
### Monitoring a UDP endpoint
By prefixing `endpoints[].url` with `udp://`, you can monitor UDP endpoints at a very basic level:
```yaml
endpoints:
- name: example
url: "udp://example.org:80"
conditions:
- "[CONNECTED] == true"
```
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for UDP endpoints.
This works for UDP based application.
### Monitoring a SCTP endpoint
By prefixing `endpoints[].url` with `sctp://`, you can monitor Stream Control Transmission Protocol (SCTP) endpoints at a very basic level:
```yaml
endpoints:
- name: example
url: "sctp://127.0.0.1:38412"
conditions:
- "[CONNECTED] == true"
```
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for SCTP endpoints.
This works for SCTP based application.
### Monitoring a WebSocket endpoint
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints:
```yaml
endpoints:
- name: example
url: "wss://echo.websocket.org/"
body: "status"
conditions:
- "[CONNECTED] == true"
- "[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.
### 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
By prefixing `endpoints[].url` with `icmp://`, you can monitor endpoints at a very basic level using ICMP, or more
commonly known as "ping" or "echo":
```yaml
endpoints:
- name: ping-example
url: "icmp://example.com"
conditions:
- "[CONNECTED] == true"
```
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 encounter any problems.
Prior to `v5.31.0`, some environment setups required adding `CAP_NET_RAW` capabilities to allow pings to work.
As of `v5.31.0`, this is no longer necessary, and ICMP checks will work with unprivileged pings unless running as root. See #1346 for details.
### Monitoring an endpoint using DNS queries
Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:
```yaml
endpoints:
- name: example-dns-query
url: "8.8.8.8" # Address of the DNS server to use
dns:
query-name: "example.com"
query-type: "A"
conditions:
- "[BODY] == 93.184.215.14"
- "[DNS_RCODE] == NOERROR"
```
There are two placeholders that can be used in the conditions for endpoints of type DNS:
- The placeholder `[BODY]` resolves to the output of the query. For instance, a query of type `A` would return an IPv4.
- The placeholder `[DNS_RCODE]` resolves to the name associated to the response code returned by the query, such as
`NOERROR`, `FORMERR`, `SERVFAIL`, `NXDOMAIN`, etc.
### Monitoring an endpoint using SSH
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
```yaml
endpoints:
# Password-based SSH example
- name: ssh-example-password
url: "ssh://example.com:22" # port is optional. Default is 22.
ssh:
username: "username"
password: "password"
body: |
{
"command": "echo '{\"memory\": {\"used\": 512}}'"
}
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
- "[BODY].memory.used > 500"
# Key-based SSH example
- name: ssh-example-key
url: "ssh://example.com:22" # port is optional. Default is 22.
ssh:
username: "username"
private-key: |
-----BEGIN RSA PRIVATE KEY-----
TESTRSAKEY...
-----END RSA PRIVATE KEY-----
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
```
you can also use no authentication to monitor the endpoint by not specifying the username,
password and private key fields.
```yaml
endpoints:
- name: ssh-example
url: "ssh://example.com:22" # port is optional. Default is 22.
ssh:
username: ""
password: ""
private-key: ""
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
```
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
### Monitoring an endpoint using STARTTLS
If you have an email server that you want to ensure there are no problems with, monitoring it through STARTTLS
will serve as a good initial indicator:
```yaml
endpoints:
- name: starttls-smtp-example
url: "starttls://smtp.gmail.com:587"
interval: 30m
client:
timeout: 5s
conditions:
- "[CONNECTED] == true"
- "[CERTIFICATE_EXPIRATION] > 48h"
```
### Monitoring an endpoint using TLS
Monitoring endpoints using SSL/TLS encryption, such as LDAP over TLS, can help detect certificate expiration:
```yaml
endpoints:
- name: tls-ldaps-example
url: "tls://ldap.example.com:636"
interval: 30m
client:
timeout: 5s
conditions:
- "[CONNECTED] == true"
- "[CERTIFICATE_EXPIRATION] > 48h"
```
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for TLS endpoints.
### Monitoring domain expiration
You can monitor the expiration of a domain with all endpoint types except for DNS by using the `[DOMAIN_EXPIRATION]`
placeholder:
```yaml
endpoints:
- name: check-domain-and-certificate-expiration
url: "https://example.org"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
- "[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
> [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`.
### Concurrency
By default, Gatus allows up to 3 endpoints/suites to be monitored concurrently. This provides a balance between performance and resource usage while maintaining accurate response time measurements.
You can configure the concurrency level using the `concurrency` parameter:
```yaml
# Allow 10 endpoints/suites to be monitored concurrently
concurrency: 10
# Allow unlimited concurrent monitoring
concurrency: 0
# Use default concurrency (3)
# concurrency: 3
```
**Important considerations:**
- Higher concurrency can improve monitoring performance when you have many endpoints
- Conditions using the `[RESPONSE_TIME]` placeholder may be less accurate with very high concurrency due to system resource contention
- Set to `0` for unlimited concurrency (equivalent to the deprecated `disable-monitoring-lock: true`)
**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're using Gatus for load testing scenarios
**Legacy configuration:**
The `disable-monitoring-lock` parameter is deprecated but still supported for backward compatibility. It's equivalent to setting `concurrency: 0`.
### Reloading configuration on the fly
For the sake of convenience, Gatus automatically reloads the configuration on the fly if the loaded configuration file
is updated while Gatus is running.
By default, the application will exit if the updating configuration is invalid, but you can configure
Gatus to continue running if the configuration file is updated with an invalid configuration by
setting `skip-invalid-config-update` to `true`.
Keep in mind that it is in your best interest to ensure the validity of the configuration file after each update you
apply to the configuration file while Gatus is running by looking at the log and making sure that you do not see the
following message:
```
The configuration file was updated, but it is not valid. The old configuration will continue being used.
```
Failure to do so may result in Gatus being unable to start if the application is restarted for whatever reason.
I recommend not setting `skip-invalid-config-update` to `true` to avoid a situation like this, but the choice is yours
to make.
**If you are not using a file storage**, updating the configuration while Gatus is running is effectively
the same as restarting the application.
> 📝 Updates may not be detected if the config file is bound instead of the config folder. See [#151](https://github.com/TwiN/gatus/issues/151).
### Endpoint groups
Endpoint groups are used for grouping multiple endpoints together on the dashboard.
```yaml
endpoints:
- name: frontend
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: backend
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: monitoring
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: nas
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- name: random endpoint that is not part of a group
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
```
The configuration above will result in a dashboard that looks like this when sorting by group:

### How do I sort by group by default?
Set `ui.default-sort-by` to `group` in the configuration file:
```yaml
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.
### Exposing Gatus on a custom path
Currently, you can expose the Gatus UI using a fully qualified domain name (FQDN) such as `status.example.org`. However, it does not support path-based routing, which means you cannot expose it through a URL like `example.org/status/`.
For more information, see https://github.com/TwiN/gatus/issues/88.
### Exposing Gatus on a custom port
By default, Gatus is exposed on port `8080`, but you may specify a different port by setting the `web.port` parameter:
```yaml
web:
port: 8081
```
If you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment
variable instead see [Use environment variables in config files](#use-environment-variables-in-config-files).
### Use environment variables in config files
You can use environment variables directly in the configuration file which will be substituted from the environment:
```yaml
web:
port: ${PORT}
ui:
title: $TITLE
```
⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
### Configuring a startup delay
If, for any reason, you need Gatus to wait for a given amount of time before monitoring the endpoints on application start, you can use the `GATUS_DELAY_START_SECONDS` environment variable to make Gatus sleep on startup.
### Keeping your configuration small
While not specific to Gatus, you can leverage YAML anchors to create a default configuration.
If you have a large configuration file, this should help you keep things clean.
Example
```yaml
default-endpoint: &defaults
group: core
interval: 5m
client:
insecure: true
timeout: 30s
conditions:
- "[STATUS] == 200"
endpoints:
- name: anchor-example-1
<<: *defaults # This will merge the configuration under &defaults with this endpoint
url: "https://example.org"
- name: anchor-example-2
<<: *defaults
group: example # This will override the group defined in &defaults
url: "https://example.com"
- name: anchor-example-3
<<: *defaults
url: "https://twin.sh/health"
conditions: # This will override the conditions defined in &defaults
- "[STATUS] == 200"
- "[BODY].status == UP"
```
### Proxy client configuration
You can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration.
```yaml
endpoints:
- name: website
url: "https://twin.sh/health"
client:
proxy-url: http://proxy.example.com:8080
conditions:
- "[STATUS] == 200"
```
### How to fix 431 Request Header Fields Too Large error
Depending on where your environment is deployed and what kind of middleware or reverse proxy sits in front of Gatus,
you may run into this issue. This could be because the request headers are too large, e.g. big cookies.
By default, `web.read-buffer-size` is set to `8192`, but increasing this value like so will increase the read buffer size:
```yaml
web:
read-buffer-size: 32768
```
### Badges
#### Uptime




Gatus can automatically generate an SVG badge for one of your monitored endpoints.
This allows you to put badges in your individual applications' README or even create your own status page if you
desire.
The path to generate a badge is the following:
```
/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `_` 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:
```
https://example.com/api/v1/endpoints/core_frontend/uptimes/7d/badge.svg
```
If you want to display an endpoint that is not part of a group, you must leave the group value empty:
```
https://example.com/api/v1/endpoints/_frontend/uptimes/7d/badge.svg
```
Example:
```

```
If you'd like to see a visual example of each badge available, you can simply navigate to the endpoint's detail page.
#### Health

The path to generate a badge is the following:
```
/api/v1/endpoints/{key}/health/badge.svg
```
Where:
- `{key}` has the pattern `_` 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:
```
https://example.com/api/v1/endpoints/core_frontend/health/badge.svg
```
#### Health (Shields.io)

The path to generate a badge is the following:
```
/api/v1/endpoints/{key}/health/badge.shields
```
Where:
- `{key}` has the pattern `_` 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:
```
https://example.com/api/v1/endpoints/core_frontend/health/badge.shields
```
See more information about the Shields.io badge endpoint [here](https://shields.io/badges/endpoint-badge).
#### Response time




The endpoint to generate a badge is the following:
```
/api/v1/endpoints/{key}/response-times/{duration}/badge.svg
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `_` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.
#### Response time (chart)



The endpoint to generate a response time chart is the following:
```
/api/v1/endpoints/{key}/response-times/{duration}/chart.svg
```
Where:
- `{duration}` is `30d`, `7d`, or `24h`
- `{key}` has the pattern `_` 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.
The values in the array correspond to the levels [Awesome, Great, Good, Passable, Bad]
All five values must be given in milliseconds (ms).
```yaml
endpoints:
- name: nas
group: internal
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
ui:
badge:
response-time:
thresholds: [550, 850, 1350, 1650, 1750]
```
### API
Gatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history.
All endpoints are available via a GET request to the following endpoint:
```
/api/v1/endpoints/statuses
````
Example: https://status.twin.sh/api/v1/endpoints/statuses
Specific endpoints can also be queried by using the following pattern:
```
/api/v1/endpoints/{group}_{endpoint}/statuses
```
Example: https://status.twin.sh/api/v1/endpoints/core_blog-home/statuses
Gzip compression will be used if the `Accept-Encoding` HTTP header contains `gzip`.
The API will return a JSON payload with the `Content-Type` response header set to `application/json`.
No such header is required to query the API.
#### Interacting with the API programmatically
See [TwiN/gatus-sdk](https://github.com/TwiN/gatus-sdk)
#### Raw Data
Gatus exposes the raw data for one of your monitored endpoints.
This allows you to track and aggregate data in your own applications for monitored endpoints. For instance if you want to track uptime for a period longer than 7 days.
##### Uptime
The path to get raw uptime data for an endpoint is:
```
/api/v1/endpoints/{key}/uptimes/{duration}
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `_` 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:
```
https://example.com/api/v1/endpoints/core_frontend/uptimes/24h
```
##### Response Time
The path to get raw response time data for an endpoint is:
```
/api/v1/endpoints/{key}/response-times/{duration}
```
Where:
- `{duration}` is `30d`, `7d`, `24h` or `1h`
- `{key}` has the pattern `_` 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:
```
https://example.com/api/v1/endpoints/core_frontend/response-times/24h
```
### Installing as binary
You can download Gatus as a binary using the following command:
```
go install github.com/TwiN/gatus/v5@latest
```
### High level design overview

================================================
FILE: alerting/alert/alert.go
================================================
package alert
import (
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
)
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
type Alert struct {
// Type of alert (required)
Type Type `yaml:"type"`
// Enabled defines whether the alert is enabled
//
// Use Alert.IsEnabled() to retrieve the value of this field.
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work.
Enabled *bool `yaml:"enabled,omitempty"`
// FailureThreshold is the number of failures in a row needed before triggering the alert
FailureThreshold int `yaml:"failure-threshold"`
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"`
// MinimumReminderInterval is the interval between reminders
MinimumReminderInterval time.Duration `yaml:"minimum-reminder-interval,omitempty"`
// Description of the alert. Will be included in the alert sent.
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work.
Description *string `yaml:"description,omitempty"`
// SendOnResolved defines whether to send a second notification when the issue has been resolved
//
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
// ProviderOverride is an optional field that can be used to override the provider's configuration
// It is freeform so that it can be used for any provider-specific configuration.
ProviderOverride map[string]any `yaml:"provider-override,omitempty"`
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
// ongoing/triggered incidents
ResolveKey string `yaml:"-"`
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
// should be set back to false. It is used to prevent the same alert from going out twice.
//
// This value should only be modified if the provider.AlertProvider's Send function does not return an error for an
// alert that hasn't been triggered yet. This doubles as a lazy retry. The reason why this behavior isn't also
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
// some reason, the alert provider always returns errors when trying to send the resolved notification
// (SendOnResolved).
Triggered bool `yaml:"-"`
}
// ValidateAndSetDefaults validates the alert's configuration and sets the default value of fields that have one
func (alert *Alert) ValidateAndSetDefaults() error {
if alert.FailureThreshold <= 0 {
alert.FailureThreshold = 3
}
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
}
return nil
}
// GetDescription retrieves the description of the alert
func (alert *Alert) GetDescription() string {
if alert.Description == nil {
return ""
}
return *alert.Description
}
// IsEnabled returns whether an alert is enabled or not
// Returns true if not set
func (alert *Alert) IsEnabled() bool {
if alert.Enabled == nil {
return true
}
return *alert.Enabled
}
// IsSendingOnResolved returns whether an alert is sending on resolve or not
func (alert *Alert) IsSendingOnResolved() bool {
if alert.SendOnResolved == nil {
return false
}
return *alert.SendOnResolved
}
// Checksum returns a checksum of the alert
// Used to determine which persisted triggered alert should be deleted on application start
func (alert *Alert) Checksum() string {
hash := sha256.New()
hash.Write([]byte(string(alert.Type) + "_" +
strconv.FormatBool(alert.IsEnabled()) + "_" +
strconv.FormatBool(alert.IsSendingOnResolved()) + "_" +
strconv.Itoa(alert.SuccessThreshold) + "_" +
strconv.Itoa(alert.FailureThreshold) + "_" +
alert.GetDescription()),
)
return hex.EncodeToString(hash.Sum(nil))
}
func (alert *Alert) ProviderOverrideAsBytes() []byte {
yamlBytes, err := yaml.Marshal(alert.ProviderOverride)
if err != nil {
logr.Warnf("[alert.ProviderOverrideAsBytes] Failed to marshal alert override of type=%s as bytes: %v", alert.Type, err)
}
return yamlBytes
}
================================================
FILE: alerting/alert/alert_test.go
================================================
package alert
import (
"errors"
"testing"
"time"
)
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
invalidDescription := "\""
scenarios := []struct {
name string
alert Alert
expectedError error
expectedSuccessThreshold int
expectedFailureThreshold int
}{
{
name: "valid-empty",
alert: Alert{
Description: nil,
FailureThreshold: 0,
SuccessThreshold: 0,
},
expectedError: nil,
expectedFailureThreshold: 3,
expectedSuccessThreshold: 2,
},
{
name: "invalid-description",
alert: Alert{
Description: &invalidDescription,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: ErrAlertWithInvalidDescription,
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) {
if err := scenario.alert.ValidateAndSetDefaults(); !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
t.Errorf("expected success threshold %v, got %v", scenario.expectedSuccessThreshold, scenario.alert.SuccessThreshold)
}
if scenario.alert.FailureThreshold != scenario.expectedFailureThreshold {
t.Errorf("expected failure threshold %v, got %v", scenario.expectedFailureThreshold, scenario.alert.FailureThreshold)
}
})
}
}
func TestAlert_IsEnabled(t *testing.T) {
if !(&Alert{Enabled: nil}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
}
if value := false; (&Alert{Enabled: &value}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
}
if value := true; !(&Alert{Enabled: &value}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
}
}
func TestAlert_GetDescription(t *testing.T) {
if (&Alert{Description: nil}).GetDescription() != "" {
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
}
if value := "description"; (&Alert{Description: &value}).GetDescription() != value {
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
}
}
func TestAlert_IsSendingOnResolved(t *testing.T) {
if (&Alert{SendOnResolved: nil}).IsSendingOnResolved() {
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
}
if value := false; (&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
}
if value := true; !(&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
}
}
func TestAlert_Checksum(t *testing.T) {
description1, description2 := "a", "b"
yes, no := true, false
scenarios := []struct {
name string
alert Alert
expected string
}{
{
name: "barebone",
alert: Alert{
Type: TypeDiscord,
},
expected: "fed0580e44ed5701dbba73afa1f14b2c53ca5a7b8067a860441c212916057fe3",
},
{
name: "with-description-1",
alert: Alert{
Type: TypeDiscord,
Description: &description1,
},
expected: "005f407ebe506e74a4aeb46f74c28b376debead7011e1b085da3840f72ba9707",
},
{
name: "with-description-2",
alert: Alert{
Type: TypeDiscord,
Description: &description2,
},
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
},
{
name: "with-description-2-and-enabled-false",
alert: Alert{
Type: TypeDiscord,
Enabled: &no,
Description: &description2,
},
expected: "837945c2b4cd5e961db3e63e10c348d4f1c3446ba68cf5a48e35a1ae22cf0c22",
},
{
name: "with-description-2-and-enabled-true",
alert: Alert{
Type: TypeDiscord,
Enabled: &yes, // it defaults to true if not set, but just to make sure
Description: &description2,
},
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
},
{
name: "with-description-2-and-enabled-true-and-send-on-resolved-true",
alert: Alert{
Type: TypeDiscord,
Enabled: &yes,
SendOnResolved: &yes,
Description: &description2,
},
expected: "bf1436995a880eb4a352c74c5dfee1f1b5ff6b9fc55aef9bf411b3631adfd80c",
},
{
name: "with-description-2-and-failure-threshold-7",
alert: Alert{
Type: TypeSlack,
FailureThreshold: 7,
Description: &description2,
},
expected: "8bd479e18bda393d4c924f5a0d962e825002168dedaa88b445e435db7bacffd3",
},
{
name: "with-description-2-and-failure-threshold-9",
alert: Alert{
Type: TypeSlack,
FailureThreshold: 9,
Description: &description2,
},
expected: "5abdfce5236e344996d264d526e769c07cb0d3d329a999769a1ff84b157ca6f1",
},
{
name: "with-description-2-and-success-threshold-5",
alert: Alert{
Type: TypeSlack,
SuccessThreshold: 7,
Description: &description2,
},
expected: "c0000e73626b80e212cfc24830de7094568f648e37f3e16f9e68c7f8ef75c34c",
},
{
name: "with-description-2-and-success-threshold-1",
alert: Alert{
Type: TypeSlack,
SuccessThreshold: 1,
Description: &description2,
},
expected: "5c28963b3a76104cfa4a0d79c89dd29ec596c8cfa4b1af210ec83d6d41587b5f",
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
scenario.alert.ValidateAndSetDefaults()
if checksum := scenario.alert.Checksum(); checksum != scenario.expected {
t.Errorf("expected checksum %v, got %v", scenario.expected, checksum)
}
})
}
}
================================================
FILE: alerting/alert/type.go
================================================
package alert
// Type is the type of the alert.
// The value will generally be the name of the alert provider
type Type string
const (
// TypeAWSSES is the Type for the awsses alerting provider
TypeAWSSES Type = "aws-ses"
// TypeClickUp is the Type for the clickup alerting provider
TypeClickUp Type = "clickup"
// TypeCustom is the Type for the custom alerting provider
TypeCustom Type = "custom"
// TypeDatadog is the Type for the datadog alerting provider
TypeDatadog Type = "datadog"
// TypeDiscord is the Type for the discord alerting provider
TypeDiscord Type = "discord"
// TypeEmail is the Type for the email alerting provider
TypeEmail Type = "email"
// TypeGitHub is the Type for the github alerting provider
TypeGitHub Type = "github"
// TypeGitLab is the Type for the gitlab alerting provider
TypeGitLab Type = "gitlab"
// TypeGitea is the Type for the gitea alerting provider
TypeGitea Type = "gitea"
// TypeGoogleChat is the Type for the googlechat alerting provider
TypeGoogleChat Type = "googlechat"
// TypeGotify is the Type for the gotify alerting provider
TypeGotify Type = "gotify"
// TypeHomeAssistant is the Type for the homeassistant alerting provider
TypeHomeAssistant Type = "homeassistant"
// TypeIFTTT is the Type for the ifttt alerting provider
TypeIFTTT Type = "ifttt"
// TypeIlert is the Type for the ilert alerting provider
TypeIlert Type = "ilert"
// TypeIncidentIO is the Type for the incident-io alerting provider
TypeIncidentIO Type = "incident-io"
// TypeLine is the Type for the line alerting provider
TypeLine Type = "line"
// TypeMatrix is the Type for the matrix alerting provider
TypeMatrix Type = "matrix"
// TypeMattermost is the Type for the mattermost alerting provider
TypeMattermost Type = "mattermost"
// TypeMessagebird is the Type for the messagebird alerting provider
TypeMessagebird Type = "messagebird"
// 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"
// TypeOpsgenie is the Type for the opsgenie alerting provider
TypeOpsgenie Type = "opsgenie"
// TypePagerDuty is the Type for the pagerduty alerting provider
TypePagerDuty Type = "pagerduty"
// TypePlivo is the Type for the plivo alerting provider
TypePlivo Type = "plivo"
// TypePushover is the Type for the pushover alerting provider
TypePushover Type = "pushover"
// TypeRocketChat is the Type for the rocketchat alerting provider
TypeRocketChat Type = "rocketchat"
// TypeSendGrid is the Type for the sendgrid alerting provider
TypeSendGrid Type = "sendgrid"
// TypeSignal is the Type for the signal alerting provider
TypeSignal Type = "signal"
// TypeSIGNL4 is the Type for the signl4 alerting provider
TypeSIGNL4 Type = "signl4"
// TypeSlack is the Type for the slack alerting provider
TypeSlack Type = "slack"
// TypeSplunk is the Type for the splunk alerting provider
TypeSplunk Type = "splunk"
// TypeSquadcast is the Type for the squadcast alerting provider
TypeSquadcast Type = "squadcast"
// TypeTeams is the Type for the teams alerting provider
TypeTeams Type = "teams"
// TypeTeamsWorkflows is the Type for the teams-workflows alerting provider
TypeTeamsWorkflows Type = "teams-workflows"
// TypeTelegram is the Type for the telegram alerting provider
TypeTelegram Type = "telegram"
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
// TypeVonage is the Type for the vonage alerting provider
TypeVonage Type = "vonage"
// TypeWebex is the Type for the webex alerting provider
TypeWebex Type = "webex"
// TypeZapier is the Type for the zapier alerting provider
TypeZapier Type = "zapier"
// TypeZulip is the Type for the Zulip alerting provider
TypeZulip Type = "zulip"
)
================================================
FILE: alerting/config.go
================================================
package alerting
import (
"reflect"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
"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/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"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
"github.com/TwiN/gatus/v5/alerting/provider/signal"
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
"github.com/TwiN/gatus/v5/alerting/provider/webex"
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/logr"
)
// Config is the configuration for alerting providers
type Config struct {
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
// ClickUp is the configuration for the clickup alerting provider
ClickUp *clickup.AlertProvider `yaml:"clickup,omitempty"`
// Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
// Datadog is the configuration for the datadog alerting provider
Datadog *datadog.AlertProvider `yaml:"datadog,omitempty"`
// Discord is the configuration for the discord alerting provider
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
// 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"`
// GitLab is the configuration for the gitlab alerting provider
GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"`
// Gitea is the configuration for the gitea alerting provider
Gitea *gitea.AlertProvider `yaml:"gitea,omitempty"`
// GoogleChat is the configuration for the googlechat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
// 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"`
// Line is the configuration for the line alerting provider
Line *line.AlertProvider `yaml:"line,omitempty"`
// Matrix is the configuration for the matrix alerting provider
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
// Mattermost is the configuration for the mattermost alerting provider
Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"`
// Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
// 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"`
// Opsgenie is the configuration for the opsgenie alerting provider
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
// PagerDuty is the configuration for the pagerduty alerting provider
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
// Plivo is the configuration for the plivo alerting provider
Plivo *plivo.AlertProvider `yaml:"plivo,omitempty"`
// Pushover is the configuration for the pushover alerting provider
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
// RocketChat is the configuration for the rocketchat alerting provider
RocketChat *rocketchat.AlertProvider `yaml:"rocketchat,omitempty"`
// SendGrid is the configuration for the sendgrid alerting provider
SendGrid *sendgrid.AlertProvider `yaml:"sendgrid,omitempty"`
// Signal is the configuration for the signal alerting provider
Signal *signal.AlertProvider `yaml:"signal,omitempty"`
// SIGNL4 is the configuration for the signl4 alerting provider
SIGNL4 *signl4.AlertProvider `yaml:"signl4,omitempty"`
// Slack is the configuration for the slack alerting provider
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
// Splunk is the configuration for the splunk alerting provider
Splunk *splunk.AlertProvider `yaml:"splunk,omitempty"`
// Squadcast is the configuration for the squadcast alerting provider
Squadcast *squadcast.AlertProvider `yaml:"squadcast,omitempty"`
// Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
// TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector
TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"`
// Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Vonage is the configuration for the vonage alerting provider
Vonage *vonage.AlertProvider `yaml:"vonage,omitempty"`
// Webex is the configuration for the webex alerting provider
Webex *webex.AlertProvider `yaml:"webex,omitempty"`
// Zapier is the configuration for the zapier alerting provider
Zapier *zapier.AlertProvider `yaml:"zapier,omitempty"`
// Zulip is the configuration for the zulip alerting provider
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
}
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
entityType := reflect.TypeOf(config).Elem()
for i := 0; i < entityType.NumField(); i++ {
field := entityType.Field(i)
tag := strings.Split(field.Tag.Get("yaml"), ",")[0]
if tag == string(alertType) {
fieldValue := reflect.ValueOf(config).Elem().Field(i)
if fieldValue.IsNil() {
return nil
}
return fieldValue.Interface().(provider.AlertProvider)
}
}
logr.Infof("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
return nil
}
// SetAlertingProviderToNil Sets an alerting provider to nil to avoid having to revalidate it every time an
// alert of its corresponding type is sent.
func (config *Config) SetAlertingProviderToNil(p provider.AlertProvider) {
entityType := reflect.TypeOf(config).Elem()
for i := 0; i < entityType.NumField(); i++ {
field := entityType.Field(i)
if field.Type == reflect.TypeOf(p) {
reflect.ValueOf(config).Elem().Field(i).Set(reflect.Zero(field.Type))
}
}
}
================================================
FILE: alerting/provider/awsses/awsses.go
================================================
package awsses
import (
"context"
"errors"
"fmt"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"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"
)
const (
CharSet = "UTF-8"
)
var (
ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrMissingFromOrToFields = errors.New("from and to fields are required")
ErrInvalidAWSAuthConfig = errors.New("either both or neither of access-key-id and secret-access-key must be specified")
)
type Config struct {
AccessKeyID string `yaml:"access-key-id"`
SecretAccessKey string `yaml:"secret-access-key"`
Region string `yaml:"region"`
From string `yaml:"from"`
To string `yaml:"to"`
}
func (cfg *Config) Validate() error {
if len(cfg.From) == 0 || len(cfg.To) == 0 {
return ErrMissingFromOrToFields
}
if !((len(cfg.AccessKeyID) == 0 && len(cfg.SecretAccessKey) == 0) || (len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0)) {
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
// otherwise if neither are specified, then we'll fall back on IAM authentication.
return ErrInvalidAWSAuthConfig
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AccessKeyID) > 0 {
cfg.AccessKeyID = override.AccessKeyID
}
if len(override.SecretAccessKey) > 0 {
cfg.SecretAccessKey = override.SecretAccessKey
}
if len(override.Region) > 0 {
cfg.Region = override.Region
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
ctx := context.Background()
svc, err := provider.createClient(ctx, cfg)
if err != nil {
return err
}
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(cfg.To, ",")
input := &ses.SendEmailInput{
Destination: &types.Destination{
ToAddresses: emails,
},
Message: &types.Message{
Body: &types.Body{
Text: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(body),
},
},
Subject: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(subject),
},
},
Source: aws.String(cfg.From),
}
if _, err = svc.SendEmail(ctx, input); err != nil {
return err
}
return nil
}
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 {
opts = append(opts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")))
}
awsConfig, err := config.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, err
}
return ses.NewFromConfig(awsConfig), nil
}
// buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\nCondition results:\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
return subject, message + description + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/awsses/awsses_test.go
================================================
package awsses
import (
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
invalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}}
if err := invalidProviderWithOneKey.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
validProviderWithKeys := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}}
if err := validProviderWithKeys.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{To: "to@example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{To: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "to@example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedSubject string
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if subject != scenario.ExpectedSubject {
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
}
if body != scenario.ExpectedBody {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_getConfigWithOverrides(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "groupto@example.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
},
{
Name: "provider-with-override-specify-group-but-alert-override-should-override-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "sekrit"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{
ProviderOverride: map[string]any{
"to": "alertto@example.com",
"access-key-id": 123,
},
},
ExpectedOutput: Config{To: "alertto@example.com", From: "from@example.com", AccessKeyID: "123", SecretAccessKey: "sekrit"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if got.To != scenario.ExpectedOutput.To {
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
if got.AccessKeyID != scenario.ExpectedOutput.AccessKeyID {
t.Errorf("expected AccessKeyID to be %s, got %s", scenario.ExpectedOutput.AccessKeyID, got.AccessKeyID)
}
if got.SecretAccessKey != scenario.ExpectedOutput.SecretAccessKey {
t.Errorf("expected SecretAccessKey to be %s, got %s", scenario.ExpectedOutput.SecretAccessKey, got.SecretAccessKey)
}
if got.Region != scenario.ExpectedOutput.Region {
t.Errorf("expected Region to be %s, got %s", scenario.ExpectedOutput.Region, got.Region)
}
// 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)
}
})
}
}
================================================
FILE: alerting/provider/clickup/clickup.go
================================================
package clickup
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrListIDNotSet = errors.New("list-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrInvalidPriority = errors.New("priority must be one of: urgent, high, normal, low, none")
)
var priorityMap = map[string]int{
"urgent": 1,
"high": 2,
"normal": 3,
"low": 4,
"none": 0,
}
type Config struct {
APIURL string `yaml:"api-url"`
ListID string `yaml:"list-id"`
Token string `yaml:"token"`
Assignees []string `yaml:"assignees"`
Status string `yaml:"status"`
Priority string `yaml:"priority"`
NotifyAll *bool `yaml:"notify-all,omitempty"`
Name string `yaml:"name,omitempty"`
MarkdownContent string `yaml:"content,omitempty"`
}
func (cfg *Config) Validate() error {
if cfg.ListID == "" {
return ErrListIDNotSet
}
if cfg.Token == "" {
return ErrTokenNotSet
}
if cfg.Priority == "" {
cfg.Priority = "normal"
}
if _, ok := priorityMap[cfg.Priority]; !ok {
return ErrInvalidPriority
}
if cfg.NotifyAll == nil {
defaultNotifyAll := true
cfg.NotifyAll = &defaultNotifyAll
}
if cfg.APIURL == "" {
cfg.APIURL = "https://api.clickup.com/api/v2"
}
if cfg.Name == "" {
cfg.Name = "Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]"
}
if cfg.MarkdownContent == "" {
cfg.MarkdownContent = "Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.APIURL != "" {
cfg.APIURL = override.APIURL
}
if override.ListID != "" {
cfg.ListID = override.ListID
}
if override.Token != "" {
cfg.Token = override.Token
}
if override.Status != "" {
cfg.Status = override.Status
}
if override.Priority != "" {
cfg.Priority = override.Priority
}
if override.NotifyAll != nil {
cfg.NotifyAll = override.NotifyAll
}
if len(override.Assignees) > 0 {
cfg.Assignees = override.Assignees
}
if override.Name != "" {
cfg.Name = override.Name
}
if override.MarkdownContent != "" {
cfg.MarkdownContent = override.MarkdownContent
}
}
// AlertProvider is the configuration necessary for sending an alert using ClickUp
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default configuration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
if resolved {
return provider.CloseTask(cfg, ep)
}
// Replace placeholders
name := strings.ReplaceAll(cfg.Name, "[ENDPOINT_GROUP]", ep.Group)
name = strings.ReplaceAll(name, "[ENDPOINT_NAME]", ep.Name)
markdownContent := strings.ReplaceAll(cfg.MarkdownContent, "[ENDPOINT_GROUP]", ep.Group)
markdownContent = strings.ReplaceAll(markdownContent, "[ENDPOINT_NAME]", ep.Name)
markdownContent = strings.ReplaceAll(markdownContent, "[ALERT_DESCRIPTION]", alert.GetDescription())
markdownContent = strings.ReplaceAll(markdownContent, "[RESULT_ERRORS]", strings.Join(result.Errors, ", "))
body := map[string]interface{}{
"name": name,
"markdown_content": markdownContent,
"assignees": cfg.Assignees,
"status": cfg.Status,
"notify_all": *cfg.NotifyAll,
}
if cfg.Priority != "none" {
body["priority"] = priorityMap[cfg.Priority]
}
return provider.CreateTask(cfg, body)
}
func (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
createURL := fmt.Sprintf("%s/list/%s/task", cfg.APIURL, cfg.ListID)
req, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to create task, status: %d", resp.StatusCode)
}
return nil
}
func (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error {
fetchURL := fmt.Sprintf("%s/list/%s/task?include_closed=false", cfg.APIURL, cfg.ListID)
req, err := http.NewRequest("GET", fetchURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to fetch tasks, status: %d", resp.StatusCode)
}
var fetchResponse struct {
Tasks []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"tasks"`
}
if err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil {
return err
}
var matchingTaskIDs []string
for _, task := range fetchResponse.Tasks {
if strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) {
matchingTaskIDs = append(matchingTaskIDs, task.ID)
}
}
if len(matchingTaskIDs) == 0 {
return fmt.Errorf("no matching tasks found for %s:%s", ep.Group, ep.Name)
}
for _, taskID := range matchingTaskIDs {
if err := provider.UpdateTaskStatus(cfg, taskID, "closed"); err != nil {
return fmt.Errorf("failed to close task %s: %v", taskID, err)
}
}
return nil
}
func (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error {
updateURL := fmt.Sprintf("%s/task/%s", cfg.APIURL, taskID)
body := map[string]interface{}{"status": status}
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", updateURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to update task %s, status: %d", taskID, resp.StatusCode)
}
return nil
}
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/clickup/clickup_test.go
================================================
package clickup
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: "", Token: "test-token"}}
if err := invalidProviderNoListID.Validate(); err == nil {
t.Error("provider shouldn't have been valid without list-id")
}
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: ""}}
if err := invalidProviderNoToken.Validate(); err == nil {
t.Error("provider shouldn't have been valid without token")
}
invalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "invalid"}}
if err := invalidProviderBadPriority.Validate(); err == nil {
t.Error("provider shouldn't have been valid with invalid priority")
}
validProvider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
if validProvider.DefaultConfig.Priority != "normal" {
t.Errorf("expected default priority to be 'normal', got '%s'", validProvider.DefaultConfig.Priority)
}
validProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", APIURL: "https://api.clickup.com/api/v2"}}
if err := validProviderWithAPIURL.Validate(); err != nil {
t.Error("provider should've been valid")
}
validProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}}
if err := validProviderWithPriority.Validate(); err != nil {
t.Error("provider should've been valid with priority 'urgent'")
}
validProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}}
if err := validProviderWithNone.Validate(); err != nil {
t.Error("provider should've been valid with priority 'none'")
}
}
func TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
if err := provider.Validate(); err != nil {
t.Error("provider should've been valid")
}
if provider.DefaultConfig.APIURL != "https://api.clickup.com/api/v2" {
t.Errorf("expected APIURL to be set to default, got %s", provider.DefaultConfig.APIURL)
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint endpoint.Endpoint
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "POST" && r.URL.Path == "/api/v2/list/test-list-id/task" {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "GET" {
// Mock fetch tasks response
tasksResponse := map[string]interface{}{
"tasks": []map[string]interface{}{
{
"id": "task-123",
"name": "Health Check: endpoint-group:endpoint-name",
},
},
}
body, _ := json.Marshal(tasksResponse)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
}
}
if r.Method == "PUT" {
// Mock update task status response
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-no-matching-tasks",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "GET" {
// Mock fetch tasks response with no matching tasks
tasksResponse := map[string]interface{}{
"tasks": []map[string]interface{}{},
}
body, _ := json.Marshal(tasksResponse)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved-error-fetching-tasks",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
Errors: []string{"error1", "error2"},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "normal"},
},
{
Name: "provider-with-alert-override-should-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"list-id": "override-list-id",
"token": "override-token",
}},
ExpectedOutput: Config{ListID: "override-list-id", Token: "override-token", Priority: "normal"},
},
{
Name: "provider-with-partial-alert-override-should-merge",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Status: "in progress"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"status": "closed",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Status: "closed", Priority: "normal"},
},
{
Name: "provider-with-assignees-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"assignees": []string{"user1", "user2"},
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Assignees: []string{"user1", "user2"}, Priority: "normal"},
},
{
Name: "provider-with-priority-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"priority": "urgent",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"},
},
{
Name: "provider-with-none-priority",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"priority": "none",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"},
},
{
Name: "provider-with-group-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
Overrides: []Override{
{Group: "core", Config: Config{ListID: "core-list-id", Priority: "urgent"}},
},
},
InputGroup: "core",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ListID: "core-list-id", Token: "test-token", Priority: "urgent"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ListID != scenario.ExpectedOutput.ListID {
t.Errorf("expected ListID to be %s, got %s", scenario.ExpectedOutput.ListID, got.ListID)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected Token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.Status != scenario.ExpectedOutput.Status {
t.Errorf("expected Status to be %s, got %s", scenario.ExpectedOutput.Status, got.Status)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected Priority to be %s, got %s", scenario.ExpectedOutput.Priority, got.Priority)
}
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
t.Errorf("expected Assignees length to be %d, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/custom/custom.go
================================================
package custom
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrURLNotSet = errors.New("url not set")
)
type Config struct {
URL string `yaml:"url"`
Method string `yaml:"method,omitempty"`
Body string `yaml:"body,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.Method) > 0 {
cfg.Method = override.Method
}
if len(override.Body) > 0 {
cfg.Body = override.Body
}
if len(override.Headers) > 0 {
cfg.Headers = override.Headers
}
if len(override.Placeholders) > 0 {
cfg.Placeholders = override.Placeholders
}
}
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
// Technically, all alert providers should be reachable using the custom alert provider
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
body, url, method := cfg.Body, cfg.URL, cfg.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
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))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
}
if len(method) == 0 {
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range cfg.Headers {
request.Header.Set(k, v)
}
return request
}
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
func (provider *AlertProvider) GetAlertStatePlaceholderValue(cfg *Config, resolved bool) string {
status := "TRIGGERED"
if resolved {
status = "RESOLVED"
}
if _, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
if val, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
return val
}
}
return status
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/custom/custom_test.go
================================================
package custom
import (
"fmt"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
})
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
},
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
}{
{
AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: []string{}},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
}
}
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
},
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
Errors []string
}{
{
AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,",
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
Errors: []string{"error1", "error2"},
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=test \\\"error with quotes\\\"",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,test \\\"error with quotes\\\"",
Errors: []string{"test \"error with quotes\""},
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: scenario.Errors},
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_buildHTTPRequestWithCustomPlaceholder(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]",
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
}{
{
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",
},
{
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",
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{},
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_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{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
},
}
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
}
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
t.Error("expected TRIGGERED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com", Body: "default-body"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com", Headers: map[string]string{"Cache": "true"}},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com", Headers: map[string]string{"Cache": "true"}},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com", Body: "group-body"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://group-example.com", Body: "group-body"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "body": "alert-body"}},
ExpectedOutput: Config{URL: "http://alert-example.com", Body: "alert-body"},
},
{
Name: "provider-with-partial-overrides",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{Method: "POST"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"body": "alert-body"}},
ExpectedOutput: Config{URL: "http://example.com", Body: "alert-body", Method: "POST"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.Body != scenario.ExpectedOutput.Body {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedOutput.Body, got.Body)
}
if got.Headers != nil {
for key, value := range scenario.ExpectedOutput.Headers {
if got.Headers[key] != value {
t.Errorf("expected header %s to be %s, got %s", key, value, got.Headers[key])
}
}
}
// 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)
}
})
}
}
================================================
FILE: alerting/provider/datadog/datadog.go
================================================
package datadog
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrAPIKeyNotSet = errors.New("api-key not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
APIKey string `yaml:"api-key"` // Datadog API key
Site string `yaml:"site,omitempty"` // Datadog site (e.g., datadoghq.com, datadoghq.eu)
Tags []string `yaml:"tags,omitempty"` // Additional tags to include
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.Site) > 0 {
cfg.Site = override.Site
}
if len(override.Tags) > 0 {
cfg.Tags = override.Tags
}
}
// AlertProvider is the configuration necessary for sending an alert using Datadog
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
site := cfg.Site
if site == "" {
site = "datadoghq.com"
}
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
url := fmt.Sprintf("https://api.%s/api/v1/events", site)
request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("DD-API-KEY", cfg.APIKey)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to datadog alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Title string `json:"title"`
Text string `json:"text"`
Priority string `json:"priority"`
Tags []string `json:"tags"`
AlertType string `json:"alert_type"`
SourceType string `json:"source_type_name"`
DateHappened int64 `json:"date_happened,omitempty"`
}
// 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, error) {
var title, text, priority, alertType string
if resolved {
title = fmt.Sprintf("Resolved: %s", ep.DisplayName())
text = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
priority = "normal"
alertType = "success"
} else {
title = fmt.Sprintf("Alert: %s", ep.DisplayName())
text = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
priority = "normal"
alertType = "error"
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
text += fmt.Sprintf("\n\nDescription: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
text += "\n\nCondition Results:"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
text += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
}
}
tags := []string{
"source:gatus",
fmt.Sprintf("endpoint:%s", ep.Name),
fmt.Sprintf("status:%s", alertType),
}
if ep.Group != "" {
tags = append(tags, fmt.Sprintf("group:%s", ep.Group))
}
// Append custom tags
if len(cfg.Tags) > 0 {
tags = append(tags, cfg.Tags...)
}
body := Body{
Title: title,
Text: text,
Priority: priority,
Tags: tags,
AlertType: alertType,
SourceType: "gatus",
DateHappened: time.Now().Unix(),
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/datadog/datadog_test.go
================================================
package datadog
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid-us1",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
expected: nil,
},
{
name: "valid-eu",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
expected: nil,
},
{
name: "valid-with-tags",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
expected: nil,
},
{
name: "invalid-api-key",
provider: AlertProvider{DefaultConfig: Config{Site: "datadoghq.com"}},
expected: ErrAPIKeyNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "api.datadoghq.com" {
t.Errorf("expected host api.datadoghq.com, got %s", r.Host)
}
if r.URL.Path != "/api/v1/events" {
t.Errorf("expected path /api/v1/events, got %s", r.URL.Path)
}
if r.Header.Get("DD-API-KEY") != "dd-api-key-123" {
t.Errorf("expected DD-API-KEY header to be 'dd-api-key-123', got %s", r.Header.Get("DD-API-KEY"))
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["title"] == nil {
t.Error("expected 'title' field in request body")
}
title := body["title"].(string)
if !strings.Contains(title, "Alert") {
t.Errorf("expected title to contain 'Alert', got %s", title)
}
if body["alert_type"] != "error" {
t.Errorf("expected alert_type to be 'error', got %v", body["alert_type"])
}
if body["priority"] != "normal" {
t.Errorf("expected priority to be 'normal', got %v", body["priority"])
}
text := body["text"].(string)
if !strings.Contains(text, "failed 3 time(s)") {
t.Errorf("expected text to contain failure count, got %s", text)
}
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "triggered-with-tags",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
tags := body["tags"].([]interface{})
// Datadog adds 3 base tags (source, endpoint, status) + custom tags
if len(tags) < 5 {
t.Errorf("expected at least 5 tags (3 base + 2 custom), got %d", len(tags))
}
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "api.datadoghq.eu" {
t.Errorf("expected host api.datadoghq.eu, got %s", r.Host)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
title := body["title"].(string)
if !strings.Contains(title, "Resolved") {
t.Errorf("expected title to contain 'Resolved', got %s", title)
}
if body["alert_type"] != "success" {
t.Errorf("expected alert_type to be 'success', got %v", body["alert_type"])
}
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
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.StatusForbidden, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
================================================
FILE: alerting/provider/discord/discord.go
================================================
package discord
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
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 {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
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
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"`
}
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Content string `json:"content"`
Embeds []Embed `json:"embeds"`
}
type Embed struct {
Title string `json:"title"`
Description string `json:"description"`
Color int `json:"color"`
Fields []Field `json:"fields,omitempty"`
}
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline"`
}
// 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 {
var message string
var colorCode int
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)
colorCode = 3066993
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
colorCode = 15158332
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
title := ":helmet_with_white_cross: Gatus"
if cfg.Title != "" {
title = cfg.Title
}
body := Body{
Content: cfg.MessageContent,
Embeds: []Embed{
{
Title: title,
Description: message + description,
Color: colorCode,
},
},
}
if len(formattedConditionResults) > 0 {
body.Embeds[0].Fields = append(body.Embeds[0].Fields, Field{
Name: "Condition results",
Value: formattedConditionResults,
Inline: false,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/discord/discord_test.go
================================================
package discord
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
WebhookURL: "http://example.com",
},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
title := "provider-title"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "triggered-with-modified-title",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-with-webhook-override",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"webhook-url": "http://example01.com"}},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
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) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
title := "provider-title"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"content\":\"\",\"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",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"content\":\"\",\"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}]}]}",
},
{
Name: "triggered-with-modified-title",
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
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) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: conditionResults,
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
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) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
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)
}
})
}
}
================================================
FILE: alerting/provider/email/email.go
================================================
package email
import (
"crypto/tls"
"errors"
"fmt"
"math"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
gomail "gopkg.in/mail.v2"
"gopkg.in/yaml.v3"
)
var (
ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrMissingFromOrToFields = errors.New("from and to fields are required")
ErrInvalidPort = errors.New("port must be between 1 and 65535 inclusively")
ErrMissingHost = errors.New("host is required")
)
type Config struct {
From string `yaml:"from"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Host string `yaml:"host"`
Port int `yaml:"port"`
To string `yaml:"to"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.From) == 0 || len(cfg.To) == 0 {
return ErrMissingFromOrToFields
}
if cfg.Port < 1 || cfg.Port > math.MaxUint16 {
return ErrInvalidPort
}
if len(cfg.Host) == 0 {
return ErrMissingHost
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.Username) > 0 {
cfg.Username = override.Username
}
if len(override.Password) > 0 {
cfg.Password = override.Password
}
if len(override.Host) > 0 {
cfg.Host = override.Host
}
if override.Port > 0 {
cfg.Port = override.Port
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using SMTP
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
var username string
if len(cfg.Username) > 0 {
username = cfg.Username
} else {
username = cfg.From
}
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
m := gomail.NewMessage()
m.SetHeader("From", cfg.From)
m.SetHeader("To", strings.Split(cfg.To, ",")...)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", body)
var d *gomail.Dialer
if len(cfg.Password) == 0 {
// Get the domain in the From address
localName := "localhost"
fromParts := strings.Split(cfg.From, `@`)
if len(fromParts) == 2 {
localName = fromParts[1]
}
// Create a dialer with no authentication
d = &gomail.Dialer{Host: cfg.Host, Port: cfg.Port, LocalName: localName}
} else {
// Create an authenticated dialer
d = gomail.NewDialer(cfg.Host, cfg.Port, username, cfg.Password)
}
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}
return d.DialAndSend(m)
}
// buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\nCondition results:\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
var extraLabels string
if len(ep.ExtraLabels) > 0 {
extraLabels = "\n\nExtra labels:\n"
for key, value := range ep.ExtraLabels {
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
}
}
return subject, message + description + extraLabels + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/email/email_test.go
================================================
package email
import (
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{To: "to@example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{To: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
Password: "password",
Host: "smtp.gmail.com",
Port: 587,
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "to@example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
Endpoint *endpoint.Endpoint
ExpectedSubject string
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-no-extra-labels",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if subject != scenario.ExpectedSubject {
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
}
if body != scenario.ExpectedBody {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "to01@example.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "group-to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert-to@example.com", "host": "smtp.example.com", "port": 588, "password": "hunter2"}},
ExpectedOutput: Config{From: "from@example.com", To: "alert-to@example.com", Host: "smtp.example.com", Port: 588, Password: "hunter2"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected from to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if got.To != scenario.ExpectedOutput.To {
t.Errorf("expected to be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
if got.Host != scenario.ExpectedOutput.Host {
t.Errorf("expected host to be %s, got %s", scenario.ExpectedOutput.Host, got.Host)
}
if got.Port != scenario.ExpectedOutput.Port {
t.Errorf("expected port to be %d, got %d", scenario.ExpectedOutput.Port, got.Port)
}
if got.Password != scenario.ExpectedOutput.Password {
t.Errorf("expected password to be %s, got %s", scenario.ExpectedOutput.Password, got.Password)
}
// 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)
}
})
}
}
================================================
FILE: alerting/provider/gitea/gitea.go
================================================
package gitea
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"code.gitea.io/sdk/gitea"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrRepositoryURLNotSet = errors.New("repository-url not set")
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
Assignees []string `yaml:"assignees,omitempty"` // Assignees is a list of users to assign the issue to
username string
repositoryOwner string
repositoryName string
giteaClient *gitea.Client
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.RepositoryURL) == 0 {
return ErrRepositoryURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
// Validate format of the repository URL
repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil {
return err
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return ErrInvalidRepositoryURL
}
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.giteaClient != nil {
// Already validated, let's skip the rest of the validation to avoid unnecessary API calls
return nil
}
cfg.repositoryOwner = pathParts[1]
cfg.repositoryName = pathParts[2]
opts := []gitea.ClientOption{
gitea.SetToken(cfg.Token),
}
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
// add new http client for skip verify
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
opts = append(opts, gitea.SetHTTPClient(httpClient))
}
cfg.giteaClient, err = gitea.NewClient(baseURL, opts...)
if err != nil {
return err
}
user, _, err := cfg.giteaClient.GetMyUserInfo()
if err != nil {
return err
}
cfg.username = user.UserName
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.RepositoryURL) > 0 {
cfg.RepositoryURL = override.RepositoryURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.Assignees) > 0 {
cfg.Assignees = override.Assignees
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
title := "alert(gatus): " + ep.DisplayName()
if !resolved {
_, _, err = cfg.giteaClient.CreateIssue(
cfg.repositoryOwner,
cfg.repositoryName,
gitea.CreateIssueOption{
Title: title,
Body: provider.buildIssueBody(ep, alert, result),
Assignees: cfg.Assignees,
},
)
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
return nil
}
issues, _, err := cfg.giteaClient.ListRepoIssues(
cfg.repositoryOwner,
cfg.repositoryName,
gitea.ListIssueOption{
State: gitea.StateOpen,
CreatedBy: cfg.username,
ListOptions: gitea.ListOptions{
PageSize: 100,
},
},
)
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
for _, issue := range issues {
if issue.Title == title {
stateClosed := gitea.StateClosed
_, _, err = cfg.giteaClient.EditIssue(
cfg.repositoryOwner,
cfg.repositoryName,
issue.Index,
gitea.EditIssueOption{
State: &stateClosed,
},
)
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
}
}
return nil
}
// buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
return message + description + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/gitea/gitea_test.go
================================================
package gitea
import (
"net/http"
"strings"
"testing"
"code.gitea.io/sdk/gitea"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
// isIgnorableTestError checks if an error is expected during testing when making API calls with dummy credentials
func isIgnorableTestError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "user does not exist") ||
strings.Contains(errStr, "no such host") ||
strings.Contains(errStr, "invalid username, password or token") ||
strings.Contains(errStr, "dial tcp")
}
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
ExpectedError bool
}{
{
Name: "invalid",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
ExpectedError: true,
},
{
Name: "invalid-token",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
{
Name: "missing-repository-name",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}},
ExpectedError: true,
},
{
Name: "enterprise-client",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}},
ExpectedError: false,
},
{
Name: "invalid-url",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Provider.Validate()
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !isIgnorableTestError(err) {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !isIgnorableTestError(err) {
t.Error("expected no error, got", err.Error())
}
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
NoConditions bool
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
}
}
body := scenario.Provider.buildIssueBody(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
)
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://gitea.com/TwiN/alert-test", "token": "54321", "assignees": []string{"TwiN"}}},
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/alert-test", Token: "54321", Assignees: []string{"TwiN"}},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !isIgnorableTestError(err) {
t.Fatalf("unexpected error: %s", err)
}
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
t.Errorf("expected %d assignees, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
}
for i, assignee := range got.Assignees {
if assignee != scenario.ExpectedOutput.Assignees[i] {
t.Errorf("expected assignee %s, got %s", scenario.ExpectedOutput.Assignees[i], assignee)
}
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !isIgnorableTestError(err) {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/github/github.go
================================================
package github
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/go-github/v48/github"
"golang.org/x/oauth2"
"gopkg.in/yaml.v3"
)
var (
ErrRepositoryURLNotSet = errors.New("repository-url not set")
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
username string
repositoryOwner string
repositoryName string
githubClient *github.Client
}
func (cfg *Config) Validate() error {
if len(cfg.RepositoryURL) == 0 {
return ErrRepositoryURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
// Validate format of the repository URL
repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil {
return err
}
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 {
return ErrInvalidRepositoryURL
}
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != nil {
// Already validated, let's skip the rest of the validation to avoid unnecessary API calls
return nil
}
cfg.repositoryOwner = pathParts[1]
cfg.repositoryName = pathParts[2]
// Create oauth2 HTTP client with GitHub token
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: cfg.Token,
}))
// Create GitHub client
if baseURL == "https://github.com" {
cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
} else {
cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
if err != nil {
return fmt.Errorf("failed to create enterprise GitHub client: %w", err)
}
}
// Retrieve the username once to validate that the token is valid
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
user, _, err := cfg.githubClient.Users.Get(ctx, "")
if err != nil {
return fmt.Errorf("failed to retrieve GitHub user: %w", err)
}
cfg.username = *user.Login
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.RepositoryURL) > 0 {
cfg.RepositoryURL = override.RepositoryURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
title := "alert(gatus): " + ep.DisplayName()
if !resolved {
_, _, err := cfg.githubClient.Issues.Create(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueRequest{
Title: github.String(title),
Body: github.String(provider.buildIssueBody(ep, alert, result)),
})
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
} else {
issues, _, err := cfg.githubClient.Issues.ListByRepo(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueListByRepoOptions{
State: "open",
Creator: cfg.username,
ListOptions: github.ListOptions{PerPage: 100},
})
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
for _, issue := range issues {
if *issue.Title == title {
_, _, err = cfg.githubClient.Issues.Edit(context.Background(), cfg.repositoryOwner, cfg.repositoryName, *issue.Number, &github.IssueRequest{
State: github.String("closed"),
})
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
}
}
}
return nil
}
// buildIssueBody builds the body of the issue
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
return message + description + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/github/github_test.go
================================================
package github
import (
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
"github.com/google/go-github/v48/github"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
ExpectedError bool
}{
{
Name: "invalid",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
ExpectedError: true,
},
{
Name: "invalid-token",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
{
Name: "missing-repository-name",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
ExpectedError: true,
},
{
Name: "enterprise-client",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
{
Name: "invalid-url",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.com/TwiN/test", Token: "12345"}},
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Provider.Validate()
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
cfg.githubClient = github.NewClient(nil)
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
NoConditions bool
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
}
}
body := scenario.Provider.buildIssueBody(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
)
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "token": "54321"}},
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/alert-test", Token: "54321"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/gitlab/gitlab.go
================================================
package gitlab
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
const (
DefaultSeverity = "critical"
DefaultMonitoringTool = "gatus"
)
var (
ErrInvalidWebhookURL = fmt.Errorf("invalid webhook-url")
ErrAuthorizationKeyNotSet = fmt.Errorf("authorization-key not set")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
Severity string `yaml:"severity,omitempty"` // Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
MonitoringTool string `yaml:"monitoring-tool,omitempty"` // MonitoringTool overrides the name sent to gitlab. Defaults to gatus
EnvironmentName string `yaml:"environment-name,omitempty"` // EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
Service string `yaml:"service,omitempty"` // Service affected. Defaults to the endpoint's display name
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrInvalidWebhookURL
} else if _, err := url.Parse(cfg.WebhookURL); err != nil {
return ErrInvalidWebhookURL
}
if len(cfg.AuthorizationKey) == 0 {
return ErrAuthorizationKeyNotSet
}
if len(cfg.Severity) == 0 {
cfg.Severity = DefaultSeverity
}
if len(cfg.MonitoringTool) == 0 {
cfg.MonitoringTool = DefaultMonitoringTool
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.AuthorizationKey) > 0 {
cfg.AuthorizationKey = override.AuthorizationKey
}
if len(override.Severity) > 0 {
cfg.Severity = override.Severity
}
if len(override.MonitoringTool) > 0 {
cfg.MonitoringTool = override.MonitoringTool
}
if len(override.EnvironmentName) > 0 {
cfg.EnvironmentName = override.EnvironmentName
}
if len(override.Service) > 0 {
cfg.Service = override.Service
}
}
// AlertProvider is the configuration necessary for sending an alert using GitLab
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString()
}
buffer := bytes.NewBuffer(provider.buildAlertBody(cfg, ep, alert, result, resolved))
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", fmt.Sprintf("Bearer %s", cfg.AuthorizationKey))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type AlertBody struct {
Title string `json:"title,omitempty"` // The title of the alert.
Description string `json:"description,omitempty"` // A high-level summary of the problem.
StartTime string `json:"start_time,omitempty"` // The time of the alert. If none is provided, a current time is used.
EndTime string `json:"end_time,omitempty"` // The resolution time of the alert. If provided, the alert is resolved.
Service string `json:"service,omitempty"` // The affected service.
MonitoringTool string `json:"monitoring_tool,omitempty"` // The name of the associated monitoring tool.
Hosts string `json:"hosts,omitempty"` // One or more hosts, as to where this incident occurred.
Severity string `json:"severity,omitempty"` // The severity of the alert. Case-insensitive. Can be one of: critical, high, medium, low, info, unknown. Defaults to critical if missing or value is not in this list.
Fingerprint string `json:"fingerprint,omitempty"` // The unique identifier of the alert. This can be used to group occurrences of the same alert.
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
}
// buildAlertBody builds the body of the alert
func (provider *AlertProvider) buildAlertBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
service := cfg.Service
if len(service) == 0 {
service = ep.DisplayName()
}
body := AlertBody{
Title: fmt.Sprintf("alert(%s): %s", cfg.MonitoringTool, service),
StartTime: result.Timestamp.Format(time.RFC3339),
Service: service,
MonitoringTool: cfg.MonitoringTool,
Hosts: ep.URL,
GitlabEnvironmentName: cfg.EnvironmentName,
Severity: cfg.Severity,
Fingerprint: alert.ResolveKey,
}
if resolved {
body.EndTime = result.Timestamp.Format(time.RFC3339)
}
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\n## Condition results\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
var message string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
body.Description = message + description + formattedConditionResults
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/gitlab/gitlab_test.go
================================================
package gitlab
import (
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
ExpectedError bool
}{
{
Name: "invalid",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
ExpectedError: true,
},
{
Name: "missing-webhook-url",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
ExpectedError: true,
},
{
Name: "missing-authorization-key",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
ExpectedError: true,
},
{
Name: "invalid-url",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}},
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Provider.Validate()
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildAlertBody(t *testing.T) {
firstDescription := "description-1"
scenarios := []struct {
Name string
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
},
{
Name: "no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildAlertBody(
cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: false},
},
},
false,
)
if strings.TrimSpace(string(body)) != strings.TrimSpace(scenario.ExpectedBody) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345", Severity: DefaultSeverity, MonitoringTool: DefaultMonitoringTool},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "authorization-key": "54321", "severity": "info", "monitoring-tool": "not-gatus", "environment-name": "prod", "service": "example"}},
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "54321", Severity: "info", MonitoringTool: "not-gatus", EnvironmentName: "prod", Service: "example"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
if got.AuthorizationKey != scenario.ExpectedOutput.AuthorizationKey {
t.Errorf("expected AuthorizationKey %s, got %s", scenario.ExpectedOutput.AuthorizationKey, got.AuthorizationKey)
}
if got.Severity != scenario.ExpectedOutput.Severity {
t.Errorf("expected Severity %s, got %s", scenario.ExpectedOutput.Severity, got.Severity)
}
if got.MonitoringTool != scenario.ExpectedOutput.MonitoringTool {
t.Errorf("expected MonitoringTool %s, got %s", scenario.ExpectedOutput.MonitoringTool, got.MonitoringTool)
}
if got.EnvironmentName != scenario.ExpectedOutput.EnvironmentName {
t.Errorf("expected EnvironmentName %s, got %s", scenario.ExpectedOutput.EnvironmentName, got.EnvironmentName)
}
if got.Service != scenario.ExpectedOutput.Service {
t.Errorf("expected Service %s, got %s", scenario.ExpectedOutput.Service, got.Service)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/googlechat/googlechat.go
================================================
package googlechat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// AlertProvider is the configuration necessary for sending an alert using Google chat
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Cards []Cards `json:"cards"`
}
type Cards struct {
Sections []Sections `json:"sections"`
}
type Sections struct {
Widgets []Widgets `json:"widgets"`
}
type Widgets struct {
KeyValue *KeyValue `json:"keyValue,omitempty"`
Buttons []Buttons `json:"buttons,omitempty"`
}
type KeyValue struct {
TopLabel string `json:"topLabel,omitempty"`
Content string `json:"content,omitempty"`
ContentMultiline string `json:"contentMultiline,omitempty"`
BottomLabel string `json:"bottomLabel,omitempty"`
Icon string `json:"icon,omitempty"`
}
type Buttons struct {
TextButton TextButton `json:"textButton"`
}
type TextButton struct {
Text string `json:"text"`
OnClick OnClick `json:"onClick"`
}
type OnClick struct {
OpenLink OpenLink `json:"openLink"`
}
type OpenLink struct {
URL string `json:"url"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string
if resolved {
color = "#36A64F"
message = fmt.Sprintf("An alert has been resolved after passing successfully %d time(s) in a row ", color, alert.SuccessThreshold)
} else {
color = "#DD0000"
message = fmt.Sprintf("An alert has been triggered due to having failed %d time(s) in a row ", color, alert.FailureThreshold)
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s %s ", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":: " + alertDescription
}
payload := Body{
Cards: []Cards{
{
Sections: []Sections{
{
Widgets: []Widgets{
{
KeyValue: &KeyValue{
TopLabel: ep.DisplayName(),
Content: message,
ContentMultiline: "true",
BottomLabel: description,
Icon: "BOOKMARK",
},
},
},
},
},
},
},
}
if len(formattedConditionResults) > 0 {
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
KeyValue: &KeyValue{
TopLabel: "Condition results",
Content: formattedConditionResults,
ContentMultiline: "true",
Icon: "DESCRIPTION",
},
})
}
if ep.Type() == endpoint.TypeHTTP {
// We only include a button targeting the URL if the endpoint is an HTTP endpoint
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
// See https://github.com/TwiN/gatus/issues/362
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
Buttons: []Buttons{
{
TextButton: TextButton{
Text: "URL",
OnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}},
},
},
},
})
}
bodyAsJSON, _ := json.Marshal(payload)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/googlechat/googlechat_test.go
================================================
package googlechat
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Endpoint endpoint.Endpoint
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
},
{
Name: "resolved",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-2","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
},
{
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
},
{
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.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 {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/gotify/gotify.go
================================================
package gotify
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const DefaultPriority = 5
var (
ErrServerURLNotSet = errors.New("server URL not set")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
ServerURL string `yaml:"server-url"` // URL of the Gotify server
Token string `yaml:"token"` // Token to use when sending a message to the Gotify server
Priority int `yaml:"priority,omitempty"` // Priority of the message. Defaults to DefaultPriority.
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
if cfg.Priority == 0 {
cfg.Priority = DefaultPriority
}
if len(cfg.ServerURL) == 0 {
return ErrServerURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ServerURL) > 0 {
cfg.ServerURL = override.ServerURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if override.Priority != 0 {
cfg.Priority = override.Priority
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Gotify
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.ServerURL+"/message?token="+cfg.Token, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("failed to send alert to Gotify: %s", string(body))
}
return nil
}
type Body struct {
Message string `json:"message"`
Title string `json:"title"`
Priority int `json:"priority"`
}
// 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 {
var message string
if resolved {
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✓"
} else {
prefix = "✕"
}
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += formattedConditionResults
title := "Gatus: " + ep.DisplayName()
if cfg.Title != "" {
title = cfg.Title
}
bodyAsJSON, _ := json.Marshal(Body{
Message: message,
Title: title,
Priority: cfg.Priority,
})
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/gotify/gotify_test.go
================================================
package gotify
import (
"encoding/json"
"fmt"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true,
},
{
name: "invalid-server-url",
provider: AlertProvider{DefaultConfig: Config{ServerURL: "", Token: "faketoken"}},
expected: false,
},
{
name: "invalid-app-token",
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: ""}},
expected: false,
},
{
name: "no-priority-should-use-default-value",
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if err := scenario.provider.Validate(); (err == nil) != scenario.expected {
t.Errorf("expected: %t, got: %t", scenario.expected, err == nil)
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
var (
description = "custom-description"
//title = "custom-title"
endpointName = "custom-endpoint"
)
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
},
{
Name: "custom-title",
Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description),
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: endpointName},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
provider := AlertProvider{DefaultAlert: &alert.Alert{}}
if provider.GetDefaultAlert() != provider.DefaultAlert {
t.Error("expected default alert to be returned")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{ServerURL: "https://gotify.example.com", Token: "12345", Priority: DefaultPriority},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://gotify.group-example.com", "token": "54321", "title": "alert-title", "priority": 3}},
ExpectedOutput: Config{ServerURL: "https://gotify.group-example.com", Token: "54321", Title: "alert-title", Priority: 3},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.ServerURL != scenario.ExpectedOutput.ServerURL {
t.Errorf("expected server URL to be %s, got %s", scenario.ExpectedOutput.ServerURL, got.ServerURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.Title != scenario.ExpectedOutput.Title {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedOutput.Title, got.Title)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected priority to be %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/homeassistant/homeassistant.go
================================================
package homeassistant
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrURLNotSet = errors.New("url not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using HomeAssistant
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/events/gatus_alert", cfg.URL), 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
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
EventType string `json:"event_type"`
EventData struct {
Status string `json:"status"`
Endpoint string `json:"endpoint"`
Description string `json:"description,omitempty"`
Conditions []struct {
Condition string `json:"condition"`
Success bool `json:"success"`
} `json:"conditions,omitempty"`
FailureCount int `json:"failure_count,omitempty"`
SuccessCount int `json:"success_count,omitempty"`
} `json:"event_data"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{
EventType: "gatus_alert",
EventData: struct {
Status string `json:"status"`
Endpoint string `json:"endpoint"`
Description string `json:"description,omitempty"`
Conditions []struct {
Condition string `json:"condition"`
Success bool `json:"success"`
} `json:"conditions,omitempty"`
FailureCount int `json:"failure_count,omitempty"`
SuccessCount int `json:"success_count,omitempty"`
}{
Status: "resolved",
Endpoint: ep.DisplayName(),
},
}
if !resolved {
body.EventData.Status = "triggered"
body.EventData.FailureCount = alert.FailureThreshold
} else {
body.EventData.SuccessCount = alert.SuccessThreshold
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
body.EventData.Description = alertDescription
}
if len(result.ConditionResults) > 0 {
for _, conditionResult := range result.ConditionResults {
body.EventData.Conditions = append(body.EventData.Conditions, struct {
Condition string `json:"condition"`
Success bool `json:"success"`
}{
Condition: conditionResult.Condition,
Success: conditionResult.Success,
})
}
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/homeassistant/homeassistant_test.go
================================================
package homeassistant
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{URL: "", Token: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: ""}}
if err := invalidProviderNoToken.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{URL: "http://homeassistant:8123", Token: "token"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"},
Overrides: []Override{
{
Config: Config{URL: "http://homeassistant:8123", Token: "token"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "SUCCESSFUL_CONDITION", Success: true},
{Condition: "FAILING_CONDITION", Success: false},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
description := "test-description"
provider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
body := provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "SUCCESSFUL_CONDITION", Success: true},
{Condition: "FAILING_CONDITION", Success: false},
},
},
false,
)
var decodedBody Body
if err := json.Unmarshal(body, &decodedBody); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if decodedBody.EventType != "gatus_alert" {
t.Errorf("expected event_type to be gatus_alert, got %s", decodedBody.EventType)
}
if decodedBody.EventData.Status != "triggered" {
t.Errorf("expected status to be triggered, got %s", decodedBody.EventData.Status)
}
if decodedBody.EventData.Description != description {
t.Errorf("expected description to be %s, got %s", description, decodedBody.EventData.Description)
}
if len(decodedBody.EventData.Conditions) != 2 {
t.Errorf("expected 2 conditions, got %d", len(decodedBody.EventData.Conditions))
}
if !decodedBody.EventData.Conditions[0].Success {
t.Error("expected first condition to be successful")
}
if decodedBody.EventData.Conditions[1].Success {
t.Error("expected second condition to be unsuccessful")
}
}
================================================
FILE: alerting/provider/ifttt/ifttt.go
================================================
package ifttt
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookKeyNotSet = errors.New("webhook-key not set")
ErrEventNameNotSet = errors.New("event-name not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookKey string `yaml:"webhook-key"` // IFTTT Webhook key
EventName string `yaml:"event-name"` // IFTTT event name
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookKey) == 0 {
return ErrWebhookKeyNotSet
}
if len(cfg.EventName) == 0 {
return ErrEventNameNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookKey) > 0 {
cfg.WebhookKey = override.WebhookKey
}
if len(override.EventName) > 0 {
cfg.EventName = override.EventName
}
}
// AlertProvider is the configuration necessary for sending an alert using IFTTT
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
url := fmt.Sprintf("https://maker.ifttt.com/trigger/%s/with/key/%s", cfg.EventName, cfg.WebhookKey)
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to ifttt alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Value1 string `json:"value1"` // Alert status/title
Value2 string `json:"value2"` // Alert message
Value3 string `json:"value3"` // Additional details
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var value1, value2, value3 string
if resolved {
value1 = fmt.Sprintf("✅ RESOLVED: %s", ep.DisplayName())
value2 = fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row", alert.SuccessThreshold)
} else {
value1 = fmt.Sprintf("🚨 ALERT: %s", ep.DisplayName())
value2 = fmt.Sprintf("Endpoint has failed %d time(s) in a row", alert.FailureThreshold)
}
// Build additional details
value3 = fmt.Sprintf("Endpoint: %s", ep.DisplayName())
if ep.Group != "" {
value3 += fmt.Sprintf(" | Group: %s", ep.Group)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
value3 += fmt.Sprintf(" | Description: %s", alertDescription)
}
// Add condition results summary
if len(result.ConditionResults) > 0 {
successCount := 0
for _, conditionResult := range result.ConditionResults {
if conditionResult.Success {
successCount++
}
}
value3 += fmt.Sprintf(" | Conditions: %d/%d passed", successCount, len(result.ConditionResults))
}
body := Body{
Value1: value1,
Value2: value2,
Value3: value3,
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/ifttt/ifttt_test.go
================================================
package ifttt
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
expected: nil,
},
{
name: "invalid-webhook-key",
provider: AlertProvider{DefaultConfig: Config{EventName: "gatus_alert"}},
expected: ErrWebhookKeyNotSet,
},
{
name: "invalid-event-name",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123"}},
expected: ErrEventNameNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "maker.ifttt.com" {
t.Errorf("expected host maker.ifttt.com, got %s", r.Host)
}
if r.URL.Path != "/trigger/gatus_alert/with/key/ifttt-webhook-key-123" {
t.Errorf("expected path /trigger/gatus_alert/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
value1 := body["value1"].(string)
if !strings.Contains(value1, "ALERT") {
t.Errorf("expected value1 to contain 'ALERT', got %s", value1)
}
value2 := body["value2"].(string)
if !strings.Contains(value2, "failed 3 time(s)") {
t.Errorf("expected value2 to contain failure count, got %s", value2)
}
value3 := body["value3"].(string)
if !strings.Contains(value3, "Endpoint: endpoint-name") {
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_resolved"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.URL.Path != "/trigger/gatus_resolved/with/key/ifttt-webhook-key-123" {
t.Errorf("expected path /trigger/gatus_resolved/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
value1 := body["value1"].(string)
if !strings.Contains(value1, "RESOLVED") {
t.Errorf("expected value1 to contain 'RESOLVED', got %s", value1)
}
value3 := body["value3"].(string)
if !strings.Contains(value3, "Endpoint: endpoint-name") {
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
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.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
================================================
FILE: alerting/provider/ilert/ilert.go
================================================
package ilert
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
restAPIUrl = "https://api.ilert.com/api/v1/events/gatus/"
)
var (
ErrIntegrationKeyNotSet = errors.New("integration key is not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
IntegrationKey string `yaml:"integration-key"`
}
func (cfg *Config) Validate() error {
if len(cfg.IntegrationKey) == 0 {
return ErrIntegrationKeyNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.IntegrationKey) > 0 {
cfg.IntegrationKey = override.IntegrationKey
}
}
// AlertProvider is the configuration necessary for sending an alert using ilert
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"`
}
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", restAPIUrl, cfg.IntegrationKey), buffer)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Alert alert.Alert `json:"alert"`
Name string `json:"name"`
Group string `json:"group"`
Status string `json:"status"`
Title string `json:"title"`
Details string `json:"details,omitempty"`
ConditionResults []*endpoint.ConditionResult `json:"condition_results"`
URL string `json:"url"`
}
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var details, status string
if resolved {
status = "resolved"
} else {
status = "firing"
}
if len(alert.GetDescription()) > 0 {
details = alert.GetDescription()
} else {
details = "No description"
}
var body []byte
body, _ = json.Marshal(Body{
Alert: *alert,
Name: ep.Name,
Group: ep.Group,
Title: ep.DisplayName(),
Status: status,
Details: details,
ConditionResults: result.ConditionResults,
URL: ep.URL,
})
return body
}
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/ilert/ilert_test.go
================================================
package ilert
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{
DefaultConfig: Config{
IntegrationKey: "some-random-key",
},
},
expected: true,
},
{
name: "invalid-integration-key",
provider: AlertProvider{
DefaultConfig: Config{
IntegrationKey: "",
},
},
expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
}
})
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid, got error:", err.Error())
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
sendOnResolved := true
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{
IntegrationKey: "some-integration-key",
}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{
IntegrationKey: "some-integration-key",
}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{
IntegrationKey: "some-integration-key",
}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_BuildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
sendOnResolved := true
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":3,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 4, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: true,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":4,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"resolved","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":true},{"condition":"[STATUS] == 200","success":true}],"url":""}`,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}, Overrides: []Override{{Group: "g", Config: Config{IntegrationKey: "different-integration-key"}}}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: false,
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":5,"MinimumReminderInterval":0,"Description":"description-2","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-2","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
}
// 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)
}
})
}
}
================================================
FILE: alerting/provider/incidentio/dedup.go
================================================
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[:])
}
================================================
FILE: alerting/provider/incidentio/incidentio.go
================================================
package incidentio
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"strconv"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
)
const (
restAPIUrl = "https://api.incident.io/v2/alert_events/http/"
)
var (
ErrURLNotSet = errors.New("url not set")
ErrURLNotPrefixedWithRestAPIURL = fmt.Errorf("url must be prefixed with %s", restAPIUrl)
ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrAuthTokenNotSet = errors.New("auth-token not set")
)
type Config struct {
URL string `yaml:"url,omitempty"`
AuthToken string `yaml:"auth-token,omitempty"`
SourceURL string `yaml:"source-url,omitempty"`
Metadata map[string]interface{} `yaml:"metadata,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
if !strings.HasPrefix(cfg.URL, restAPIUrl) {
return ErrURLNotPrefixedWithRestAPIURL
}
if len(cfg.AuthToken) == 0 {
return ErrAuthTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.AuthToken) > 0 {
cfg.AuthToken = override.AuthToken
}
if len(override.SourceURL) > 0 {
cfg.SourceURL = override.SourceURL
}
if len(override.Metadata) > 0 {
cfg.Metadata = override.Metadata
}
}
// AlertProvider is the configuration necessary for sending an alert using incident.io
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"`
}
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
req, err := http.NewRequest(http.MethodPost, cfg.URL, buffer)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.AuthToken)
response, err := client.GetHTTPClient(nil).Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
incidentioResponse := Response{}
err = json.NewDecoder(response.Body).Decode(&incidentioResponse)
if err != nil {
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
logr.Errorf("[incidentio.Send] Ran into error decoding pagerduty response: %s", err.Error())
}
alert.ResolveKey = incidentioResponse.DeduplicationKey
return err
}
type Body struct {
AlertSourceConfigID string `json:"alert_source_config_id"`
Status string `json:"status"`
Title string `json:"title"`
DeduplicationKey string `json:"deduplication_key,omitempty"`
Description string `json:"description,omitempty"`
SourceURL string `json:"source_url,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type Response struct {
DeduplicationKey string `json:"deduplication_key"`
}
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, formattedConditionResults, status string
if resolved {
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
status = "resolved"
} else {
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
status = "firing"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "🟢"
} else {
prefix = "🔴"
}
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)
// 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)
// Merge metadata: cfg.Metadata + ep.ExtraLabels (if present)
mergedMetadata := map[string]interface{}{}
// Copy cfg.Metadata
maps.Copy(mergedMetadata, cfg.Metadata)
// Add extra labels from endpoint (if present)
if ep.ExtraLabels != nil && len(ep.ExtraLabels) > 0 {
for k, v := range ep.ExtraLabels {
mergedMetadata[k] = v
}
}
body, _ := json.Marshal(Body{
AlertSourceConfigID: alertSourceID,
Title: "Gatus: " + ep.DisplayName(),
Status: status,
DeduplicationKey: alert.ResolveKey,
Description: message,
SourceURL: cfg.SourceURL,
Metadata: mergedMetadata,
})
fmt.Printf("%v", string(body))
return body
}
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/incidentio/incidentio_test.go
================================================
package incidentio
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{
DefaultConfig: Config{
URL: "https://api.incident.io/v2/alert_events/http/some-id",
AuthToken: "some-token",
},
},
expected: true,
},
{
name: "invalid-url",
provider: AlertProvider{
DefaultConfig: Config{
URL: "id-without-rest-api-url-as-prefix",
AuthToken: "some-token",
},
},
expected: false,
},
{
name: "invalid-missing-auth-token",
provider: AlertProvider{
DefaultConfig: Config{
URL: "some-id",
},
},
expected: false,
},
{
name: "invalid-missing-alert-source-config-id",
provider: AlertProvider{
DefaultConfig: Config{
AuthToken: "some-token",
},
},
expected: false,
},
{
name: "valid-override",
provider: AlertProvider{
DefaultConfig: Config{
AuthToken: "some-token",
URL: "https://api.incident.io/v2/alert_events/http/some-id",
},
Overrides: []Override{{Group: "core", Config: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id"}}},
},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError 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,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
response := Response{DeduplicationKey: "some-key"}
json.NewEncoder(&b).Encode(response)
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{
URL: restAPIUrl + "some-id",
AuthToken: "some-token",
}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{
URL: restAPIUrl + "some-id",
AuthToken: "some-token",
}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
response := Response{DeduplicationKey: "some-key"}
json.NewEncoder(&b).Encode(response)
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_BuildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
scenarios := []struct {
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,
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,
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,
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,
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,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
// 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")
}
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "https://api.incident.io/v2/alert_events/http/another-id"}},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id", AuthToken: "some-token"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected alert source config to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
t.Errorf("expected alert auth token to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
}
// 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)
}
})
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{URL: "", AuthToken: "some-token"},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/nice-id", AuthToken: "some-token"},
Overrides: []Override{
{
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/very-good-id", AuthToken: "some-token"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
================================================
FILE: alerting/provider/line/line.go
================================================
package line
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrChannelAccessTokenNotSet = errors.New("channel-access-token not set")
ErrUserIDsNotSet = errors.New("user-ids not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
ChannelAccessToken string `yaml:"channel-access-token"` // Line Messaging API channel access token
UserIDs []string `yaml:"user-ids"` // List of Line user IDs to send messages to
}
func (cfg *Config) Validate() error {
if len(cfg.ChannelAccessToken) == 0 {
return ErrChannelAccessTokenNotSet
}
if len(cfg.UserIDs) == 0 {
return ErrUserIDsNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ChannelAccessToken) > 0 {
cfg.ChannelAccessToken = override.ChannelAccessToken
}
if len(override.UserIDs) > 0 {
cfg.UserIDs = override.UserIDs
}
}
// AlertProvider is the configuration necessary for sending an alert using Line
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
for _, userID := range cfg.UserIDs {
body, err := provider.buildRequestBody(ep, alert, result, resolved, userID)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, "https://api.line.me/v2/bot/message/push", buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.ChannelAccessToken))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
response.Body.Close()
return fmt.Errorf("call to line alert returned status code %d: %s", response.StatusCode, string(body))
}
response.Body.Close()
}
return nil
}
type Body struct {
To string `json:"to"`
Messages []Message `json:"messages"`
}
type Message struct {
Type string `json:"type"`
Text string `json:"text"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, userID string) ([]byte, error) {
var message string
if resolved {
message = fmt.Sprintf("✅ RESOLVED: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("⚠️ ALERT: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
message += "\n\nCondition Results:"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
}
}
body := Body{
To: userID,
Messages: []Message{
{
Type: "text",
Text: message,
},
},
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/line/line_test.go
================================================
package line
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
expected: nil,
},
{
name: "invalid-channel-access-token",
provider: AlertProvider{DefaultConfig: Config{UserIDs: []string{"U123"}}},
expected: ErrChannelAccessTokenNotSet,
},
{
name: "invalid-user-ids",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123"}},
expected: ErrUserIDsNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123", "U456"}}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.URL.Path != "/v2/bot/message/push" {
t.Errorf("expected path /v2/bot/message/push, got %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer token123" {
t.Errorf("expected Authorization header to be 'Bearer token123', got %s", r.Header.Get("Authorization"))
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["to"] == nil {
t.Error("expected 'to' field in request body")
}
messages := body["messages"].([]interface{})
if len(messages) != 1 {
t.Errorf("expected 1 message, got %d", len(messages))
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
messages := body["messages"].([]interface{})
message := messages[0].(map[string]interface{})
text := message["text"].(string)
if !contains(text, "RESOLVED") {
t.Errorf("expected message to contain 'RESOLVED', got %s", text)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
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.StatusBadRequest, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && s[0:len(substr)] == substr || len(s) > len(substr) && contains(s[1:], substr)
}
================================================
FILE: alerting/provider/matrix/matrix.go
================================================
package matrix
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const defaultServerURL = "https://matrix-client.matrix.org"
var (
ErrAccessTokenNotSet = errors.New("access-token not set")
ErrInternalRoomID = errors.New("internal-room-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
// ServerURL is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"`
// AccessToken is the bot user's access token to send messages
AccessToken string `yaml:"access-token"`
// InternalRoomID is the room that the bot user has permissions to send messages to
InternalRoomID string `yaml:"internal-room-id"`
}
func (cfg *Config) Validate() error {
if len(cfg.ServerURL) == 0 {
cfg.ServerURL = defaultServerURL
}
if len(cfg.AccessToken) == 0 {
return ErrAccessTokenNotSet
}
if len(cfg.InternalRoomID) == 0 {
return ErrInternalRoomID
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ServerURL) > 0 {
cfg.ServerURL = override.ServerURL
}
if len(override.AccessToken) > 0 {
cfg.AccessToken = override.AccessToken
}
if len(override.InternalRoomID) > 0 {
cfg.InternalRoomID = override.InternalRoomID
}
}
// AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
// The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24)
request, err := http.NewRequest(
http.MethodPut,
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
cfg.ServerURL,
url.PathEscape(cfg.InternalRoomID),
txnId,
url.QueryEscape(cfg.AccessToken),
),
buffer,
)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
MsgType string `json:"msgtype"`
Format string `json:"format"`
Body string `json:"body"`
FormattedBody string `json:"formatted_body"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body, _ := json.Marshal(Body{
MsgType: "m.text",
Format: "org.matrix.custom.html",
Body: buildPlaintextMessageBody(ep, alert, result, resolved),
FormattedBody: buildHTMLMessageBody(ep, alert, result, resolved),
})
return body
}
// buildPlaintextMessageBody builds the message body in plaintext to include in request
func buildPlaintextMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string
if resolved {
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✓"
} else {
prefix = "✕"
}
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n" + alertDescription
}
return fmt.Sprintf("%s%s\n%s", message, description, formattedConditionResults)
}
// buildHTMLMessageBody builds the message body in HTML to include in request
func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string
if resolved {
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\nCondition results "
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s - %s ", prefix, conditionResult.Condition)
}
formattedConditionResults += " "
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = fmt.Sprintf("\n%s ", alertDescription)
}
return fmt.Sprintf("%s %s%s", message, description, formattedConditionResults)
}
func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
return string(b)
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/matrix/matrix_test.go
================================================
package matrix
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
AccessToken: "",
InternalRoomID: "",
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
DefaultConfig: Config{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
validProviderWithHomeserver := AlertProvider{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
}
if err := validProviderWithHomeserver.Validate(); err != nil {
t.Error("provider with homeserver should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Group: "",
Config: Config{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Group: "group",
Config: Config{
AccessToken: "",
InternalRoomID: "",
},
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered-with-bad-config",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"body\":\"An alert for `endpoint-name` has been triggered due to having failed 3 time(s) in a row\\ndescription-1\\n\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been triggered due to having failed 3 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-1\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"body\":\"An alert for `endpoint-name` has been resolved after passing successfully 5 time(s) in a row\\ndescription-2\\n\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been resolved after passing successfully 5 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-2\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:group-example.com",
},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:group-example.com",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:group-example.com",
},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:example01.com",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
ExpectedOutput: Config{
ServerURL: "https://alert-example.com",
AccessToken: "123",
InternalRoomID: "!a:alert-example.com",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if outputConfig.ServerURL != scenario.ExpectedOutput.ServerURL {
t.Errorf("expected ServerURL to be %s, got %s", scenario.ExpectedOutput.ServerURL, outputConfig.ServerURL)
}
if outputConfig.AccessToken != scenario.ExpectedOutput.AccessToken {
t.Errorf("expected AccessToken to be %s, got %s", scenario.ExpectedOutput.AccessToken, outputConfig.AccessToken)
}
if outputConfig.InternalRoomID != scenario.ExpectedOutput.InternalRoomID {
t.Errorf("expected InternalRoomID to be %s, got %s", scenario.ExpectedOutput.InternalRoomID, outputConfig.InternalRoomID)
}
// 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)
}
})
}
}
================================================
FILE: alerting/provider/mattermost/mattermost.go
================================================
package mattermost
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook URL not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
Channel string `yaml:"channel,omitempty"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Channel) > 0 {
cfg.Channel = override.Channel
}
}
// AlertProvider is the configuration necessary for sending an alert using Mattermost
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
if provider.Overrides != nil {
registeredGroups := make(map[string]bool)
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Channel string `json:"channel,omitempty"` // Optional channel override
Text string `json:"text"`
Username string `json:"username"`
IconURL string `json:"icon_url"`
Attachments []Attachment `json:"attachments"`
}
type Attachment struct {
Title string `json:"title"`
Fallback string `json:"fallback"`
Text string `json:"text"`
Short bool `json:"short"`
Color string `json:"color"`
Fields []Field `json:"fields"`
}
type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
// 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 {
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)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000"
}
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
body := Body{
Channel: cfg.Channel,
Text: "",
Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
Attachments: []Attachment{
{
Title: ":helmet_with_white_cross: Gatus",
Fallback: "Gatus - " + message,
Text: message + description,
Short: false,
Color: color,
},
},
}
if len(formattedConditionResults) > 0 {
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
Title: "Condition results",
Value: formattedConditionResults,
Short: false,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/mattermost/mattermost_test.go
================================================
package mattermost
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideWebHookUrl := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
t.Error("provider WebHookURL shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\"text\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\"text\":\"An alert for *endpoint-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) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.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 {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/messagebird/messagebird.go
================================================
package messagebird
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const restAPIURL = "https://rest.messagebird.com/messages"
var (
ErrorAccessKeyNotSet = errors.New("access-key not set")
ErrorOriginatorNotSet = errors.New("originator not set")
ErrorRecipientsNotSet = errors.New("recipients not set")
)
type Config struct {
AccessKey string `yaml:"access-key"`
Originator string `yaml:"originator"`
Recipients string `yaml:"recipients"`
}
func (cfg *Config) Validate() error {
if len(cfg.AccessKey) == 0 {
return ErrorAccessKeyNotSet
}
if len(cfg.Originator) == 0 {
return ErrorOriginatorNotSet
}
if len(cfg.Recipients) == 0 {
return ErrorRecipientsNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AccessKey) > 0 {
cfg.AccessKey = override.AccessKey
}
if len(override.Originator) > 0 {
cfg.Originator = override.Originator
}
if len(override.Recipients) > 0 {
cfg.Recipients = override.Recipients
}
}
// AlertProvider is the configuration necessary for sending an alert using Messagebird
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", cfg.AccessKey))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Originator string `json:"originator"`
Recipients string `json:"recipients"`
Body string `json:"body"`
}
// 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 {
var message string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
body, _ := json.Marshal(Body{
Originator: cfg.Originator,
Recipients: cfg.Recipients,
Body: message,
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/messagebird/messagebird_test.go
================================================
package messagebird
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
DefaultConfig: Config{
AccessKey: "1",
Originator: "1",
Recipients: "1",
},
}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "4", Originator: "5", Recipients: "6"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"access-key": "4", "originator": "5", "recipients": "6"}},
ExpectedOutput: Config{AccessKey: "4", Originator: "5", Recipients: "6"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.AccessKey != scenario.ExpectedOutput.AccessKey {
t.Errorf("expected access key to be %s, got %s", scenario.ExpectedOutput.AccessKey, got.AccessKey)
}
if got.Originator != scenario.ExpectedOutput.Originator {
t.Errorf("expected originator to be %s, got %s", scenario.ExpectedOutput.Originator, got.Originator)
}
if got.Recipients != scenario.ExpectedOutput.Recipients {
t.Errorf("expected recipients to be %s, got %s", scenario.ExpectedOutput.Recipients, got.Recipients)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/n8n/n8n.go
================================================
package n8n
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
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.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
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 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"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
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 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 {
var message string
if resolved {
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
title := "Gatus"
if cfg.Title != "" {
title = cfg.Title
}
var conditionResults []ConditionResult
for _, conditionResult := range result.ConditionResults {
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
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/n8n/n8n_test.go
================================================
package n8n
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider webhook URL shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint endpoint.Endpoint
Alert alert.Alert
Resolved bool
ExpectedBody Body
}{
{
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{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{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-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(
cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
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)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.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 {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/newrelic/newrelic.go
================================================
package newrelic
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrInsertKeyNotSet = errors.New("insert-key not set")
ErrAccountIDNotSet = errors.New("account-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
InsertKey string `yaml:"insert-key"` // New Relic Insert key
AccountID string `yaml:"account-id"` // New Relic account ID
Region string `yaml:"region,omitempty"` // Region (US or EU, defaults to US)
}
func (cfg *Config) Validate() error {
if len(cfg.InsertKey) == 0 {
return ErrInsertKeyNotSet
}
if len(cfg.AccountID) == 0 {
return ErrAccountIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.InsertKey) > 0 {
cfg.InsertKey = override.InsertKey
}
if len(override.AccountID) > 0 {
cfg.AccountID = override.AccountID
}
if len(override.Region) > 0 {
cfg.Region = override.Region
}
}
// AlertProvider is the configuration necessary for sending an alert using New Relic
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
// Determine the API endpoint based on region
var apiHost string
if cfg.Region == "EU" {
apiHost = "insights-collector.eu01.nr-data.net"
} else {
apiHost = "insights-collector.newrelic.com"
}
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
url := fmt.Sprintf("https://%s/v1/accounts/%s/events", apiHost, cfg.AccountID)
request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Insert-Key", cfg.InsertKey)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to newrelic alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Event struct {
EventType string `json:"eventType"`
Timestamp int64 `json:"timestamp"`
Service string `json:"service"`
Endpoint string `json:"endpoint"`
Group string `json:"group,omitempty"`
AlertStatus string `json:"alertStatus"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
Severity string `json:"severity"`
Source string `json:"source"`
SuccessRate float64 `json:"successRate,omitempty"`
}
// 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, error) {
var alertStatus, severity, message string
var successRate float64
if resolved {
alertStatus = "resolved"
severity = "INFO"
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
successRate = 100
} else {
alertStatus = "triggered"
severity = "CRITICAL"
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
successRate = 0
}
// Calculate success rate from condition results
if len(result.ConditionResults) > 0 {
successCount := 0
for _, conditionResult := range result.ConditionResults {
if conditionResult.Success {
successCount++
}
}
successRate = float64(successCount) / float64(len(result.ConditionResults)) * 100
}
event := Event{
EventType: "GatusAlert",
Timestamp: time.Now().Unix() * 1000, // New Relic expects milliseconds
Service: "Gatus",
Endpoint: ep.DisplayName(),
Group: ep.Group,
AlertStatus: alertStatus,
Message: message,
Description: alert.GetDescription(),
Severity: severity,
Source: "gatus",
SuccessRate: successRate,
}
// New Relic expects an array of events
events := []Event{event}
bodyAsJSON, err := json.Marshal(events)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/newrelic/newrelic_test.go
================================================
package newrelic
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
expected: nil,
},
{
name: "valid-with-region",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
expected: nil,
},
{
name: "invalid-insert-key",
provider: AlertProvider{DefaultConfig: Config{AccountID: "123456"}},
expected: ErrInsertKeyNotSet,
},
{
name: "invalid-account-id",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123"}},
expected: ErrAccountIDNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered-us",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "insights-collector.newrelic.com" {
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
}
if r.URL.Path != "/v1/accounts/123456/events" {
t.Errorf("expected path /v1/accounts/123456/events, got %s", r.URL.Path)
}
if r.Header.Get("X-Insert-Key") != "nr-insert-key-123" {
t.Errorf("expected X-Insert-Key header to be 'nr-insert-key-123', got %s", r.Header.Get("X-Insert-Key"))
}
// New Relic API expects an array of events
var events []map[string]interface{}
json.NewDecoder(r.Body).Decode(&events)
if len(events) != 1 {
t.Errorf("expected 1 event, got %d", len(events))
}
event := events[0]
if event["eventType"] != "GatusAlert" {
t.Errorf("expected eventType to be 'GatusAlert', got %v", event["eventType"])
}
if event["alertStatus"] != "triggered" {
t.Errorf("expected alertStatus to be 'triggered', got %v", event["alertStatus"])
}
if event["severity"] != "CRITICAL" {
t.Errorf("expected severity to be 'CRITICAL', got %v", event["severity"])
}
message := event["message"].(string)
if !strings.Contains(message, "Alert") {
t.Errorf("expected message to contain 'Alert', got %s", message)
}
if !strings.Contains(message, "failed 3 time(s)") {
t.Errorf("expected message to contain failure count, got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "triggered-eu",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
// Note: Test doesn't actually use EU region, it uses default US region
if r.Host != "insights-collector.newrelic.com" {
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
// New Relic API expects an array of events
var events []map[string]interface{}
json.NewDecoder(r.Body).Decode(&events)
if len(events) != 1 {
t.Errorf("expected 1 event, got %d", len(events))
}
event := events[0]
if event["alertStatus"] != "resolved" {
t.Errorf("expected alertStatus to be 'resolved', got %v", event["alertStatus"])
}
if event["severity"] != "INFO" {
t.Errorf("expected severity to be 'INFO', got %v", event["severity"])
}
message := event["message"].(string)
if !strings.Contains(message, "resolved") {
t.Errorf("expected message to contain 'resolved', got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
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.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
================================================
FILE: alerting/provider/ntfy/ntfy.go
================================================
package ntfy
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
DefaultURL = "https://ntfy.sh"
DefaultPriority = 3
TokenPrefix = "tk_"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrTopicNotSet = errors.New("topic not set")
ErrInvalidPriority = errors.New("priority must between 1 and 5 inclusively")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
Email string `yaml:"email,omitempty"` // Defaults to ""
Click string `yaml:"click,omitempty"` // Defaults to ""
DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false
DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
cfg.URL = DefaultURL
}
if cfg.Priority == 0 {
cfg.Priority = DefaultPriority
}
if len(cfg.Token) > 0 && !strings.HasPrefix(cfg.Token, TokenPrefix) {
return ErrInvalidToken
}
if len(cfg.Topic) == 0 {
return ErrTopicNotSet
}
if cfg.Priority < 1 || cfg.Priority > 5 {
return ErrInvalidPriority
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Topic) > 0 {
cfg.Topic = override.Topic
}
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if override.Priority > 0 {
cfg.Priority = override.Priority
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.Email) > 0 {
cfg.Email = override.Email
}
if len(override.Click) > 0 {
cfg.Click = override.Click
}
if override.DisableFirebase {
cfg.DisableFirebase = true
}
if override.DisableCache {
cfg.DisableCache = true
}
}
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if len(override.Group) == 0 {
return ErrDuplicateGroupOverride
}
if _, ok := registeredGroups[override.Group]; ok {
return ErrDuplicateGroupOverride
}
if len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) {
return ErrDuplicateGroupOverride
}
if override.Priority < 0 || override.Priority >= 6 {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := cfg.URL
request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
if token := cfg.Token; len(token) > 0 {
request.Header.Set("Authorization", "Bearer "+token)
}
if cfg.DisableFirebase {
request.Header.Set("Firebase", "no")
}
if cfg.DisableCache {
request.Header.Set("Cache", "no")
}
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Tags []string `json:"tags"`
Priority int `json:"priority"`
Email string `json:"email,omitempty"`
Click string `json:"click,omitempty"`
}
// 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 {
var message, formattedConditionResults, tag string
if resolved {
tag = "white_check_mark"
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
} else {
tag = "rotating_light"
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "🟢"
} else {
prefix = "🔴"
}
formattedConditionResults += fmt.Sprintf("\n%s %s", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += formattedConditionResults
body, _ := json.Marshal(Body{
Topic: cfg.Topic,
Title: "Gatus: " + ep.DisplayName(),
Message: message,
Tags: []string{tag},
Priority: cfg.Priority,
Email: cfg.Email,
Click: cfg.Click,
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/ntfy/ntfy_test.go
================================================
package ntfy
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
expected: true,
},
{
name: "no-url-should-use-default-value",
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1}},
expected: true,
},
{
name: "valid-with-token",
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "tk_faketoken"}},
expected: true,
},
{
name: "invalid-token",
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "xx_faketoken"}},
expected: false,
},
{
name: "invalid-topic",
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "", Priority: 1}},
expected: false,
},
{
name: "invalid-priority-too-high",
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 6}},
expected: false,
},
{
name: "invalid-priority-too-low",
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: -1}},
expected: false,
},
{
name: "no-priority-should-use-default-value",
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example"}},
expected: true,
},
{
name: "invalid-override-token",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Token: "xx_faketoken"}}}},
expected: false,
},
{
name: "invalid-override-priority",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Priority: 8}}}},
expected: false,
},
{
name: "no-override-group-name",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{}}},
expected: false,
},
{
name: "duplicate-override-group-names",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g"}, {Group: "g"}}},
expected: false,
},
{
name: "valid-override",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g1", Config: Config{Priority: 4, Click: "https://example.com"}}, {Group: "g2", Config: Config{Topic: "Example", Token: "tk_faketoken"}}}},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
},
{
Name: "triggered-email",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
},
{
Name: "resolved-email",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"group-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
},
{
Name: "alert-override",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"topic": "alert-topic"}},
Resolved: false,
ExpectedBody: `{"topic":"alert-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
description := "description-1"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
Group string
ExpectedBody string
ExpectedHeaders map[string]string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
},
},
{
Name: "token",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer tk_mytoken",
},
},
{
Name: "no firebase",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
},
},
{
Name: "no cache",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Cache": "no",
},
},
{
Name: "neither firebase & cache",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
"Cache": "no",
},
},
{
Name: "overrides",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}, Overrides: []Override{Override{Group: "other-group", Config: Config{URL: "https://example.com", Token: "tk_othertoken"}}, Override{Group: "test-group", Config: Config{Token: "tk_test_token"}}}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "test-group",
ExpectedBody: `{"topic":"example","title":"Gatus: test-group/endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer tk_test_token",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
// Start a local HTTP server
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// Test request parameters
for header, value := range scenario.ExpectedHeaders {
if value != req.Header.Get(header) {
t.Errorf("expected: %s, got: %s", value, req.Header.Get(header))
}
}
body, _ := io.ReadAll(req.Body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
// Send response to be tested
rw.Write([]byte(`OK`))
}))
// Close the server when test finishes
defer server.Close()
scenario.Provider.DefaultConfig.URL = server.URL
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if err != nil {
t.Error("Encountered an error on Send: ", err)
}
})
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "topic": "alert-topic", "priority": 3}},
ExpectedOutput: Config{URL: "http://alert-example.com", Topic: "alert-topic", Priority: 3},
},
{
Name: "provider-with-partial-overrides",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{Topic: "group-topic"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"priority": 3}},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "group-topic", Priority: 3},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected url %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.Topic != scenario.ExpectedOutput.Topic {
t.Errorf("expected topic %s, got %s", scenario.ExpectedOutput.Topic, got.Topic)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected priority %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
}
// 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)
}
})
}
}
================================================
FILE: alerting/provider/opsgenie/opsgenie.go
================================================
package opsgenie
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
restAPI = "https://api.opsgenie.com/v2/alerts"
)
var (
ErrAPIKeyNotSet = errors.New("api-key not set")
)
type Config struct {
// APIKey to use for
APIKey string `yaml:"api-key"`
// Priority to be used in Opsgenie alert payload
//
// default: P1
Priority string `yaml:"priority"`
// Source define source to be used in Opsgenie alert payload
//
// default: gatus
Source string `yaml:"source"`
// EntityPrefix is a prefix to be used in entity argument in Opsgenie alert payload
//
// default: gatus-
EntityPrefix string `yaml:"entity-prefix"`
//AliasPrefix is a prefix to be used in alias argument in Opsgenie alert payload
//
// default: gatus-healthcheck-
AliasPrefix string `yaml:"alias-prefix"`
// Tags to be used in Opsgenie alert payload
//
// default: []
Tags []string `yaml:"tags"`
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
if len(cfg.Source) == 0 {
cfg.Source = "gatus"
}
if len(cfg.EntityPrefix) == 0 {
cfg.EntityPrefix = "gatus-"
}
if len(cfg.AliasPrefix) == 0 {
cfg.AliasPrefix = "gatus-healthcheck-"
}
if len(cfg.Priority) == 0 {
cfg.Priority = "P1"
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.Priority) > 0 {
cfg.Priority = override.Priority
}
if len(override.Source) > 0 {
cfg.Source = override.Source
}
if len(override.EntityPrefix) > 0 {
cfg.EntityPrefix = override.EntityPrefix
}
if len(override.AliasPrefix) > 0 {
cfg.AliasPrefix = override.AliasPrefix
}
if len(override.Tags) > 0 {
cfg.Tags = override.Tags
}
}
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
//
// Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
err = provider.sendAlertRequest(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
if resolved {
err = provider.closeAlert(cfg, ep, alert)
if err != nil {
return err
}
}
if alert.IsSendingOnResolved() {
if resolved {
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = ""
} else {
alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
}
}
return nil
}
func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
return provider.sendRequest(cfg, restAPI, http.MethodPost, payload)
}
func (provider *AlertProvider) closeAlert(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(ep, alert)
url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
return provider.sendRequest(cfg, url, http.MethodPost, payload)
}
func (provider *AlertProvider) sendRequest(cfg *Config, url, method string, payload interface{}) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("error build alert with payload %v: %w", payload, err)
}
request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "GenieKey "+cfg.APIKey)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
rBody, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(rBody))
}
return nil
}
func (provider *AlertProvider) buildCreateRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
var message, description string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("%s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if ep.Group != "" {
message = fmt.Sprintf("[%s] %s", ep.Group, message)
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "▣"
} else {
prefix = "▢"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
description = description + "\n" + formattedConditionResults
key := buildKey(ep)
details := map[string]string{
"endpoint:url": ep.URL,
"endpoint:group": ep.Group,
"result:hostname": result.Hostname,
"result:ip": result.IP,
"result:dns_code": result.DNSRCode,
"result:errors": strings.Join(result.Errors, ","),
}
for k, v := range details {
if v == "" {
delete(details, k)
}
}
if result.HTTPStatus > 0 {
details["result:http_status"] = strconv.Itoa(result.HTTPStatus)
}
return alertCreateRequest{
Message: message,
Description: description,
Source: cfg.Source,
Priority: cfg.Priority,
Alias: cfg.AliasPrefix + key,
Entity: cfg.EntityPrefix + key,
Tags: cfg.Tags,
Details: details,
}
}
func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, alert *alert.Alert) alertCloseRequest {
return alertCloseRequest{
Source: buildKey(ep),
Note: fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()),
}
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
func buildKey(ep *endpoint.Endpoint) string {
name := toKebabCase(ep.Name)
if ep.Group == "" {
return name
}
return toKebabCase(ep.Group) + "-" + name
}
func toKebabCase(val string) string {
return strings.ToLower(strings.ReplaceAll(val, " ", "-"))
}
type alertCreateRequest struct {
Message string `json:"message"`
Priority string `json:"priority"`
Source string `json:"source"`
Entity string `json:"entity"`
Alias string `json:"alias"`
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
Details map[string]string `json:"details"`
}
type alertCloseRequest struct {
Source string `json:"source"`
Note string `json:"note"`
}
================================================
FILE: alerting/provider/opsgenie/opsgenie_test.go
================================================
package opsgenie
import (
"net/http"
"reflect"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
description := "my bad alert description"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
Resolved: false,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
t.Parallel()
description := "alert description"
scenarios := []struct {
Name string
Provider *AlertProvider
Alert *alert.Alert
Endpoint *endpoint.Endpoint
Result *endpoint.Result
Resolved bool
want alertCreateRequest
}{
{
Name: "missing all params (unresolved)",
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
Resolved: false,
want: alertCreateRequest{
Message: " - ",
Priority: "P1",
Source: "gatus",
Entity: "gatus-",
Alias: "gatus-healthcheck-",
Description: "An alert for ** has been triggered due to having failed 0 time(s) in a row\n",
Tags: nil,
Details: map[string]string{},
},
},
{
Name: "missing all params (resolved)",
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
Resolved: true,
want: alertCreateRequest{
Message: "RESOLVED: - ",
Priority: "P1",
Source: "gatus",
Entity: "gatus-",
Alias: "gatus-healthcheck-",
Description: "An alert for ** has been resolved after passing successfully 0 time(s) in a row\n",
Tags: nil,
Details: map[string]string{},
},
},
{
Name: "with default options (unresolved)",
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{
Description: &description,
FailureThreshold: 3,
},
Endpoint: &endpoint.Endpoint{
Name: "my super app",
},
Result: &endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[BODY] == OK",
Success: false,
},
},
},
Resolved: false,
want: alertCreateRequest{
Message: "my super app - " + description,
Priority: "P1",
Source: "gatus",
Entity: "gatus-my-super-app",
Alias: "gatus-healthcheck-my-super-app",
Description: "An alert for *my super app* has been triggered due to having failed 3 time(s) in a row\n▣ - `[STATUS] == 200`\n▢ - `[BODY] == OK`\n",
Tags: nil,
Details: map[string]string{},
},
},
{
Name: "with custom options (resolved)",
Provider: &AlertProvider{
DefaultConfig: Config{
Priority: "P5",
EntityPrefix: "oompa-",
AliasPrefix: "loompa-",
Source: "gatus-hc",
Tags: []string{"do-ba-dee-doo"},
},
},
Alert: &alert.Alert{
Description: &description,
SuccessThreshold: 4,
},
Endpoint: &endpoint.Endpoint{
Name: "my mega app",
},
Result: &endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
},
},
Resolved: true,
want: alertCreateRequest{
Message: "RESOLVED: my mega app - " + description,
Priority: "P5",
Source: "gatus-hc",
Entity: "oompa-my-mega-app",
Alias: "loompa-my-mega-app",
Description: "An alert for *my mega app* has been resolved after passing successfully 4 time(s) in a row\n▣ - `[STATUS] == 200`\n",
Tags: []string{"do-ba-dee-doo"},
Details: map[string]string{},
},
},
{
Name: "with default options and details (unresolved)",
Provider: &AlertProvider{
DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
},
Alert: &alert.Alert{
Description: &description,
FailureThreshold: 6,
},
Endpoint: &endpoint.Endpoint{
Name: "my app",
Group: "end game",
URL: "https://my.go/app",
},
Result: &endpoint.Result{
HTTPStatus: 400,
Hostname: "my.go",
Errors: []string{"error 01", "error 02"},
Success: false,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: false,
},
},
},
Resolved: false,
want: alertCreateRequest{
Message: "[end game] my app - " + description,
Priority: "P1",
Source: "gatus",
Entity: "gatus-end-game-my-app",
Alias: "gatus-healthcheck-end-game-my-app",
Description: "An alert for *end game/my app* has been triggered due to having failed 6 time(s) in a row\n▢ - `[STATUS] == 200`\n",
Tags: []string{"foo"},
Details: map[string]string{
"endpoint:url": "https://my.go/app",
"endpoint:group": "end game",
"result:hostname": "my.go",
"result:errors": "error 01,error 02",
"result:http_status": "400",
},
},
},
}
for _, scenario := range scenarios {
actual := scenario
t.Run(actual.Name, func(t *testing.T) {
_ = scenario.Provider.Validate()
if got := actual.Provider.buildCreateRequestBody(&scenario.Provider.DefaultConfig, actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
t.Errorf("got:\n%v\nwant:\n%v", got, actual.want)
}
})
}
}
func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
t.Parallel()
description := "alert description"
scenarios := []struct {
Name string
Provider *AlertProvider
Alert *alert.Alert
Endpoint *endpoint.Endpoint
want alertCloseRequest
}{
{
Name: "Missing all values",
Provider: &AlertProvider{},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
want: alertCloseRequest{
Source: "",
Note: "RESOLVED: - ",
},
},
{
Name: "Basic values",
Provider: &AlertProvider{},
Alert: &alert.Alert{
Description: &description,
},
Endpoint: &endpoint.Endpoint{
Name: "endpoint name",
},
want: alertCloseRequest{
Source: "endpoint-name",
Note: "RESOLVED: endpoint name - alert description",
},
},
}
for _, scenario := range scenarios {
actual := scenario
t.Run(actual.Name, func(t *testing.T) {
if got := actual.Provider.buildCloseRequestBody(actual.Endpoint, actual.Alert); !reflect.DeepEqual(got, actual.want) {
t.Errorf("buildCloseRequestBody() = %v, want %v", got, actual.want)
}
})
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
{
Name: "provider-with-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "00000000-0000-0000-0000-000000000001"}},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000001"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.APIKey != scenario.ExpectedOutput.APIKey {
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/pagerduty/pagerduty.go
================================================
package pagerduty
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
)
const (
restAPIURL = "https://events.pagerduty.com/v2/enqueue"
)
var (
ErrIntegrationKeyNotSet = errors.New("integration-key must have exactly 32 characters")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
IntegrationKey string `yaml:"integration-key"`
}
func (cfg *Config) Validate() error {
if len(cfg.IntegrationKey) != 32 {
return ErrIntegrationKeyNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.IntegrationKey) > 0 {
cfg.IntegrationKey = override.IntegrationKey
}
}
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
// Either the default integration key has the right length, or there are overrides who are properly configured.
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
//
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
if alert.IsSendingOnResolved() {
if resolved {
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = ""
} else {
// We need to retrieve the resolve key from the response
var payload pagerDutyResponsePayload
if err = json.NewDecoder(response.Body).Decode(&payload); err != nil {
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
logr.Errorf("[pagerduty.Send] Ran into error decoding pagerduty response: %s", err.Error())
} else {
alert.ResolveKey = payload.DedupKey
}
}
}
return nil
}
type Body struct {
RoutingKey string `json:"routing_key"`
DedupKey string `json:"dedup_key"`
EventAction string `json:"event_action"`
Payload Payload `json:"payload"`
}
type Payload struct {
Summary string `json:"summary"`
Source string `json:"source"`
Severity string `json:"severity"`
}
// 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 {
var message, eventAction, resolveKey string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
eventAction = "resolve"
resolveKey = alert.ResolveKey
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
eventAction = "trigger"
resolveKey = ""
}
body, _ := json.Marshal(Body{
RoutingKey: cfg.IntegrationKey,
DedupKey: resolveKey,
EventAction: eventAction,
Payload: Payload{
Summary: message,
Source: "Gatus",
Severity: "critical",
},
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
type pagerDutyResponsePayload struct {
Status string `json:"status"`
Message string `json:"message"`
DedupKey string `json:"dedup_key"`
}
================================================
FILE: alerting/provider/pagerduty/pagerduty_test.go
================================================
package pagerduty
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid, got error:", err.Error())
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
description := "test"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description},
Resolved: false,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&scenario.Provider.DefaultConfig, &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
}
// 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)
}
})
}
}
================================================
FILE: alerting/provider/plivo/plivo.go
================================================
package plivo
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrAuthIDNotSet = errors.New("auth-id not set")
ErrAuthTokenNotSet = errors.New("auth-token not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
AuthID string `yaml:"auth-id"`
AuthToken string `yaml:"auth-token"`
From string `yaml:"from"`
To []string `yaml:"to"`
}
func (cfg *Config) Validate() error {
if len(cfg.AuthID) == 0 {
return ErrAuthIDNotSet
}
if len(cfg.AuthToken) == 0 {
return ErrAuthTokenNotSet
}
if len(cfg.From) == 0 {
return ErrFromNotSet
}
if len(cfg.To) == 0 {
return ErrToNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AuthID) > 0 {
cfg.AuthID = override.AuthID
}
if len(override.AuthToken) > 0 {
cfg.AuthToken = override.AuthToken
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using Plivo
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
message := provider.buildMessage(cfg, ep, alert, result, resolved)
// Send individual SMS messages to each recipient
for _, to := range cfg.To {
if err := provider.sendSMS(cfg, to, message); err != nil {
return err
}
}
return nil
}
// sendSMS sends an SMS message to a single recipient
func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
payload := map[string]string{
"src": cfg.From,
"dst": to,
"text": message,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.plivo.com/v1/Account/%s/Message/", cfg.AuthID), bytes.NewBuffer(payloadBytes))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.AuthID+":"+cfg.AuthToken))))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to plivo alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
// buildMessage builds the message for the provider
func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
if resolved {
return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else {
return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/plivo/plivo_test.go
================================================
package plivo
import (
"encoding/json"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestPlivoAlertProvider_IsValid(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
ExpectedError error
}{
{
Name: "invalid-provider-missing-config",
Provider: AlertProvider{},
ExpectedError: ErrAuthIDNotSet,
},
{
Name: "valid-provider",
Provider: AlertProvider{
DefaultConfig: Config{
AuthID: "1",
AuthToken: "1",
From: "1234567890",
To: []string{"0987654321"},
},
},
ExpectedError: nil,
},
{
Name: "valid-provider-with-override",
Provider: AlertProvider{
DefaultConfig: Config{
AuthID: "1",
AuthToken: "1",
From: "1234567890",
To: []string{"0987654321"},
},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
},
},
},
ExpectedError: nil,
},
{
Name: "invalid-provider-duplicate-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
AuthID: "1",
AuthToken: "1",
From: "1234567890",
To: []string{"0987654321"},
},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
},
{
Group: "group1",
Config: Config{AuthID: "3", AuthToken: "3", From: "4444444444", To: []string{"5555555555"}},
},
},
},
ExpectedError: ErrDuplicateGroupOverride,
},
{
Name: "invalid-provider-empty-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
AuthID: "1",
AuthToken: "1",
From: "1234567890",
To: []string{"0987654321"},
},
Overrides: []Override{
{
Group: "",
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
},
},
},
ExpectedError: ErrDuplicateGroupOverride,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Provider.Validate()
if scenario.ExpectedError == nil && err != nil {
t.Errorf("expected no error, got %v", err)
}
if scenario.ExpectedError != nil && err == nil {
t.Errorf("expected error %v, got none", scenario.ExpectedError)
}
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
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.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "multiple-recipients",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321", "1122334455"}}},
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.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildMessage(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedMessage string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedMessage: "RESOLVED: endpoint-name - description-2",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
message := scenario.Provider.buildMessage(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if message != scenario.ExpectedMessage {
t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
}
})
}
}
func TestAlertProvider_sendSMS(t *testing.T) {
defer client.InjectHTTPClient(nil)
cfg := &Config{
AuthID: "test-auth-id",
AuthToken: "test-auth-token",
From: "1234567890",
}
scenarios := []struct {
Name string
To string
Message string
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "successful-sms",
To: "0987654321",
Message: "Test message",
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
// Verify request structure
body, _ := io.ReadAll(r.Body)
var payload map[string]string
json.Unmarshal(body, &payload)
if payload["src"] != cfg.From {
t.Errorf("expected src %s, got %s", cfg.From, payload["src"])
}
if payload["dst"] != "0987654321" {
t.Errorf("expected dst %s, got %s", "0987654321", payload["dst"])
}
if payload["text"] != "Test message" {
t.Errorf("expected text %s, got %s", "Test message", payload["text"])
}
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "failed-sms",
To: "0987654321",
Message: "Test message",
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
provider := AlertProvider{}
err := provider.sendSMS(cfg, scenario.To, scenario.Message)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
},
{
Name: "provider-with-group-override",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
},
},
},
InputGroup: "group1",
InputAlert: alert.Alert{},
ExpectedOutput: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
},
{
Name: "provider-with-group-override-no-match",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
},
},
},
InputGroup: "group2",
InputAlert: alert.Alert{},
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6", "from": "5555555555", "to": []string{"9999999999"}}},
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "5555555555", To: []string{"9999999999"}},
},
{
Name: "provider-with-group-and-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
},
},
},
InputGroup: "group1",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6"}},
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "3333333333", To: []string{"7777777777"}},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.AuthID != scenario.ExpectedOutput.AuthID {
t.Errorf("expected AuthID to be %s, got %s", scenario.ExpectedOutput.AuthID, got.AuthID)
}
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
t.Errorf("expected AuthToken to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if len(got.To) != len(scenario.ExpectedOutput.To) {
t.Errorf("expected To length to be %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
}
for i, to := range got.To {
if to != scenario.ExpectedOutput.To[i] {
t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
}
}
// 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)
}
})
}
}
func TestConfig_Validate(t *testing.T) {
scenarios := []struct {
Name string
Config Config
ExpectedError error
}{
{
Name: "valid-config",
Config: Config{
AuthID: "test-auth-id",
AuthToken: "test-auth-token",
From: "1234567890",
To: []string{"0987654321"},
},
ExpectedError: nil,
},
{
Name: "missing-auth-id",
Config: Config{
AuthToken: "test-auth-token",
From: "1234567890",
To: []string{"0987654321"},
},
ExpectedError: ErrAuthIDNotSet,
},
{
Name: "missing-auth-token",
Config: Config{
AuthID: "test-auth-id",
From: "1234567890",
To: []string{"0987654321"},
},
ExpectedError: ErrAuthTokenNotSet,
},
{
Name: "missing-from",
Config: Config{
AuthID: "test-auth-id",
AuthToken: "test-auth-token",
To: []string{"0987654321"},
},
ExpectedError: ErrFromNotSet,
},
{
Name: "missing-to",
Config: Config{
AuthID: "test-auth-id",
AuthToken: "test-auth-token",
From: "1234567890",
},
ExpectedError: ErrToNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Config.Validate()
if scenario.ExpectedError == nil && err != nil {
t.Errorf("expected no error, got %v", err)
}
if scenario.ExpectedError != nil && err == nil {
t.Errorf("expected error %v, got none", scenario.ExpectedError)
}
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestConfig_Merge(t *testing.T) {
cfg := Config{
AuthID: "original-auth-id",
AuthToken: "original-auth-token",
From: "1111111111",
To: []string{"2222222222"},
}
override := Config{
AuthID: "override-auth-id",
AuthToken: "override-auth-token",
From: "3333333333",
To: []string{"4444444444", "5555555555"},
}
cfg.Merge(&override)
if cfg.AuthID != "override-auth-id" {
t.Errorf("expected AuthID to be %s, got %s", "override-auth-id", cfg.AuthID)
}
if cfg.AuthToken != "override-auth-token" {
t.Errorf("expected AuthToken to be %s, got %s", "override-auth-token", cfg.AuthToken)
}
if cfg.From != "3333333333" {
t.Errorf("expected From to be %s, got %s", "3333333333", cfg.From)
}
if len(cfg.To) != 2 || cfg.To[0] != "4444444444" || cfg.To[1] != "5555555555" {
t.Errorf("expected To to be [4444444444, 5555555555], got %v", cfg.To)
}
}
================================================
FILE: alerting/provider/provider.go
================================================
package provider
import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
"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/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"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
"github.com/TwiN/gatus/v5/alerting/provider/signal"
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/webex"
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// AlertProvider is the interface that each provider should implement
type AlertProvider interface {
// Validate the provider's configuration
Validate() error
// Send an alert using the provider
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
// GetDefaultAlert returns the provider's default alert configuration
GetDefaultAlert() *alert.Alert
// ValidateOverrides validates the alert's provider override and, if present, the group override
ValidateOverrides(group string, alert *alert.Alert) error
}
type Config[T any] interface {
Validate() error
Merge(override *T)
}
// MergeProviderDefaultAlertIntoEndpointAlert parses an Endpoint alert by using the provider's default alert as a baseline
func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
if providerDefaultAlert == nil || endpointAlert == nil {
return
}
if endpointAlert.Enabled == nil {
endpointAlert.Enabled = providerDefaultAlert.Enabled
}
if endpointAlert.SendOnResolved == nil {
endpointAlert.SendOnResolved = providerDefaultAlert.SendOnResolved
}
if endpointAlert.Description == nil {
endpointAlert.Description = providerDefaultAlert.Description
}
if endpointAlert.FailureThreshold == 0 {
endpointAlert.FailureThreshold = providerDefaultAlert.FailureThreshold
}
if endpointAlert.SuccessThreshold == 0 {
endpointAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
}
if endpointAlert.MinimumReminderInterval == 0 {
endpointAlert.MinimumReminderInterval = providerDefaultAlert.MinimumReminderInterval
}
}
var (
// Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*clickup.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*datadog.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*gotify.AlertProvider)(nil)
_ AlertProvider = (*homeassistant.AlertProvider)(nil)
_ AlertProvider = (*ifttt.AlertProvider)(nil)
_ AlertProvider = (*ilert.AlertProvider)(nil)
_ AlertProvider = (*incidentio.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)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
_ AlertProvider = (*plivo.AlertProvider)(nil)
_ AlertProvider = (*pushover.AlertProvider)(nil)
_ AlertProvider = (*rocketchat.AlertProvider)(nil)
_ AlertProvider = (*sendgrid.AlertProvider)(nil)
_ AlertProvider = (*signal.AlertProvider)(nil)
_ AlertProvider = (*signl4.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*splunk.AlertProvider)(nil)
_ AlertProvider = (*squadcast.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*webex.AlertProvider)(nil)
_ AlertProvider = (*zapier.AlertProvider)(nil)
_ AlertProvider = (*zulip.AlertProvider)(nil)
// Validate config interface implementation on compile
_ Config[awsses.Config] = (*awsses.Config)(nil)
_ Config[clickup.Config] = (*clickup.Config)(nil)
_ Config[custom.Config] = (*custom.Config)(nil)
_ Config[datadog.Config] = (*datadog.Config)(nil)
_ Config[discord.Config] = (*discord.Config)(nil)
_ Config[email.Config] = (*email.Config)(nil)
_ Config[gitea.Config] = (*gitea.Config)(nil)
_ Config[github.Config] = (*github.Config)(nil)
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
_ Config[gotify.Config] = (*gotify.Config)(nil)
_ Config[homeassistant.Config] = (*homeassistant.Config)(nil)
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
_ Config[ilert.Config] = (*ilert.Config)(nil)
_ Config[incidentio.Config] = (*incidentio.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)
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
_ Config[plivo.Config] = (*plivo.Config)(nil)
_ Config[pushover.Config] = (*pushover.Config)(nil)
_ Config[rocketchat.Config] = (*rocketchat.Config)(nil)
_ Config[sendgrid.Config] = (*sendgrid.Config)(nil)
_ Config[signal.Config] = (*signal.Config)(nil)
_ Config[signl4.Config] = (*signl4.Config)(nil)
_ Config[slack.Config] = (*slack.Config)(nil)
_ Config[splunk.Config] = (*splunk.Config)(nil)
_ Config[squadcast.Config] = (*squadcast.Config)(nil)
_ Config[teams.Config] = (*teams.Config)(nil)
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
_ Config[telegram.Config] = (*telegram.Config)(nil)
_ Config[twilio.Config] = (*twilio.Config)(nil)
_ Config[webex.Config] = (*webex.Config)(nil)
_ Config[zapier.Config] = (*zapier.Config)(nil)
_ Config[zulip.Config] = (*zulip.Config)(nil)
)
================================================
FILE: alerting/provider/provider_test.go
================================================
package provider
import (
"testing"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
)
func TestParseWithDefaultAlert(t *testing.T) {
type Scenario struct {
Name string
DefaultAlert, EndpointAlert, ExpectedOutputAlert *alert.Alert
}
enabled := true
disabled := false
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []Scenario{
{
Name: "endpoint-alert-type-only",
DefaultAlert: &alert.Alert{
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
MinimumReminderInterval: 30 * time.Second,
},
EndpointAlert: &alert.Alert{
Type: alert.TypeDiscord,
},
ExpectedOutputAlert: &alert.Alert{
Type: alert.TypeDiscord,
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
MinimumReminderInterval: 30 * time.Second,
},
},
{
Name: "endpoint-alert-overwrites-default-alert",
DefaultAlert: &alert.Alert{
Enabled: &disabled,
SendOnResolved: &disabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
},
EndpointAlert: &alert.Alert{
Type: alert.TypeTelegram,
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &secondDescription,
FailureThreshold: 6,
SuccessThreshold: 11,
},
ExpectedOutputAlert: &alert.Alert{
Type: alert.TypeTelegram,
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &secondDescription,
FailureThreshold: 6,
SuccessThreshold: 11,
},
},
{
Name: "endpoint-alert-partially-overwrites-default-alert",
DefaultAlert: &alert.Alert{
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
},
EndpointAlert: &alert.Alert{
Type: alert.TypeDiscord,
Enabled: nil,
SendOnResolved: nil,
FailureThreshold: 6,
SuccessThreshold: 11,
},
ExpectedOutputAlert: &alert.Alert{
Type: alert.TypeDiscord,
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &firstDescription,
FailureThreshold: 6,
SuccessThreshold: 11,
},
},
{
Name: "default-alert-type-should-be-ignored",
DefaultAlert: &alert.Alert{
Type: alert.TypeTelegram,
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
},
EndpointAlert: &alert.Alert{
Type: alert.TypeDiscord,
},
ExpectedOutputAlert: &alert.Alert{
Type: alert.TypeDiscord,
Enabled: &enabled,
SendOnResolved: &enabled,
Description: &firstDescription,
FailureThreshold: 5,
SuccessThreshold: 10,
},
},
{
Name: "no-default-alert",
DefaultAlert: &alert.Alert{
Type: alert.TypeDiscord,
Enabled: nil,
SendOnResolved: nil,
Description: &firstDescription,
FailureThreshold: 2,
SuccessThreshold: 5,
},
EndpointAlert: nil,
ExpectedOutputAlert: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
MergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)
if scenario.ExpectedOutputAlert == nil {
if scenario.EndpointAlert != nil {
t.Fail()
}
return
}
if scenario.EndpointAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {
t.Errorf("expected EndpointAlert.IsEnabled() to be %v, got %v", scenario.ExpectedOutputAlert.IsEnabled(), scenario.EndpointAlert.IsEnabled())
}
if scenario.EndpointAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {
t.Errorf("expected EndpointAlert.IsSendingOnResolved() to be %v, got %v", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.EndpointAlert.IsSendingOnResolved())
}
if scenario.EndpointAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {
t.Errorf("expected EndpointAlert.GetDescription() to be %v, got %v", scenario.ExpectedOutputAlert.GetDescription(), scenario.EndpointAlert.GetDescription())
}
if scenario.EndpointAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {
t.Errorf("expected EndpointAlert.FailureThreshold to be %v, got %v", scenario.ExpectedOutputAlert.FailureThreshold, scenario.EndpointAlert.FailureThreshold)
}
if scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
t.Errorf("expected EndpointAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)
}
if int(scenario.EndpointAlert.MinimumReminderInterval) != int(scenario.ExpectedOutputAlert.MinimumReminderInterval) {
t.Errorf("expected EndpointAlert.MinimumReminderInterval to be %v, got %v", scenario.ExpectedOutputAlert.MinimumReminderInterval, scenario.EndpointAlert.MinimumReminderInterval)
}
})
}
}
================================================
FILE: alerting/provider/pushover/pushover.go
================================================
package pushover
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
ApiURL = "https://api.pushover.net/1/messages.json"
defaultPriority = 0
)
var (
ErrInvalidApplicationToken = errors.New("application-token must be 30 characters long")
ErrInvalidUserKey = errors.New("user-key must be 30 characters long")
ErrInvalidPriority = errors.New("priority and resolved-priority must be between -2 and 2")
ErrInvalidDevice = errors.New("device name must have 25 characters or less")
)
type Config struct {
// Key used to authenticate the application sending
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
ApplicationToken string `yaml:"application-token"`
// Key of the user or group the messages should be sent to
UserKey string `yaml:"user-key"`
// The title of your message
// default: "Gatus: ""
Title string `yaml:"title,omitempty"`
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
// default: 0
Priority int `yaml:"priority,omitempty"`
// Priority of resolved messages, ranging from -2 (very low) to 2 (Emergency)
// default: 0
ResolvedPriority int `yaml:"resolved-priority,omitempty"`
// Sound of the messages (see: https://pushover.net/api#sounds)
// default: "" (pushover)
Sound string `yaml:"sound,omitempty"`
// TTL of your message (https://pushover.net/api#ttl)
// If priority is 2 then this parameter is ignored
// default: 0
TTL int `yaml:"ttl,omitempty"`
// Device to send the message to (see: https://pushover.net/api#devices)
// default: "" (all devices)
Device string `yaml:"device,omitempty"`
}
func (cfg *Config) Validate() error {
if cfg.Priority == 0 {
cfg.Priority = defaultPriority
}
if cfg.ResolvedPriority == 0 {
cfg.ResolvedPriority = defaultPriority
}
if len(cfg.ApplicationToken) != 30 {
return ErrInvalidApplicationToken
}
if len(cfg.UserKey) != 30 {
return ErrInvalidUserKey
}
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
return ErrInvalidPriority
}
if len(cfg.Device) > 25 {
return ErrInvalidDevice
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ApplicationToken) > 0 {
cfg.ApplicationToken = override.ApplicationToken
}
if len(override.UserKey) > 0 {
cfg.UserKey = override.UserKey
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
if override.Priority != 0 {
cfg.Priority = override.Priority
}
if override.ResolvedPriority != 0 {
cfg.ResolvedPriority = override.ResolvedPriority
}
if len(override.Sound) > 0 {
cfg.Sound = override.Sound
}
if override.TTL > 0 {
cfg.TTL = override.TTL
}
if len(override.Device) > 0 {
cfg.Device = override.Device
}
}
// AlertProvider is the configuration necessary for sending an alert using Pushover
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, ApiURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Token string `json:"token"`
User string `json:"user"`
Title string `json:"title,omitempty"`
Message string `json:"message"`
Priority int `json:"priority"`
Html int `json:"html"`
Sound string `json:"sound,omitempty"`
TTL int `json:"ttl,omitempty"`
Device string `json:"device,omitempty"`
}
// 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 {
var message, formattedConditionResults string
priority := cfg.Priority
if resolved {
priority = cfg.ResolvedPriority
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += formattedConditionResults
title := "Gatus: " + ep.DisplayName()
if cfg.Title != "" {
title = cfg.Title
}
body, _ := json.Marshal(Body{
Token: cfg.ApplicationToken,
User: cfg.UserKey,
Title: title,
Message: message,
Priority: priority,
Html: 1,
Sound: cfg.Sound,
TTL: cfg.TTL,
Device: cfg.Device,
})
return body
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/pushover/pushover_test.go
================================================
package pushover
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestPushoverAlertProvider_IsValid(t *testing.T) {
t.Run("empty-invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{
DefaultConfig: Config{
ApplicationToken: "aTokenWithLengthOf30characters",
UserKey: "aTokenWithLengthOf30characters",
Title: "Gatus Notification",
Priority: 1,
ResolvedPriority: 1,
},
}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
})
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
UserKey: "aTokenWithLengthOfMoreThan30characters",
Priority: 5,
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider should've been invalid")
}
})
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ResolvedPriority bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been triggered due to having failed 3 time(s) in a row with the following description: description-1\\n❌ - [CONNECTED] == true\\n❌ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
},
{
Name: "triggered-customtitle",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4", Title: "Gatus Notifications"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been triggered due to having failed 3 time(s) in a row with the following description: description-1\\n❌ - [CONNECTED] == true\\n❌ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Priority: 2, ResolvedPriority: 2}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1}",
},
{
Name: "resolved-priority",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 0}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
},
{
Name: "with-sound",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, Sound: "falling"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"sound\":\"falling\"}",
},
{
Name: "with-ttl",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600}",
},
{
Name: "with-device",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600, Device: "iphone15pro",}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600,\"device\":\"iphone15pro\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"application-token": "TokenWithLengthOf30Characters2", "user-key": "TokenWithLengthOf30Characters3"}},
ExpectedOutput: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters3"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ApplicationToken != scenario.ExpectedOutput.ApplicationToken {
t.Errorf("expected application token to be %s, got %s", scenario.ExpectedOutput.ApplicationToken, got.ApplicationToken)
}
if got.UserKey != scenario.ExpectedOutput.UserKey {
t.Errorf("expected user key to be %s, got %s", scenario.ExpectedOutput.UserKey, got.UserKey)
}
// 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)
}
})
}
}
================================================
FILE: alerting/provider/rocketchat/rocketchat.go
================================================
package rocketchat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Rocket.Chat incoming webhook URL
Channel string `yaml:"channel,omitempty"` // Optional channel override
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Channel) > 0 {
cfg.Channel = override.Channel
}
}
// AlertProvider is the configuration necessary for sending an alert using Rocket.Chat
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to rocketchat alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Text string `json:"text"`
Channel string `json:"channel,omitempty"`
Username string `json:"username"`
Attachments []Attachment `json:"attachments"`
}
type Attachment struct {
Title string `json:"title"`
Text string `json:"text"`
Color string `json:"color"`
Fields []Field `json:"fields,omitempty"`
AuthorName string `json:"author_name"`
AuthorIcon string `json:"author_icon"`
}
type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
// 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, error) {
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)
color = "#36a64f"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#dd0000"
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
body := Body{
Text: "",
Username: "Gatus",
Attachments: []Attachment{
{
Title: "🚨 Gatus Alert",
Text: message + description,
Color: color,
AuthorName: "Gatus",
AuthorIcon: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
},
},
}
if cfg.Channel != "" {
body.Channel = cfg.Channel
}
if len(formattedConditionResults) > 0 {
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
Title: "Condition results",
Value: formattedConditionResults,
Short: false,
})
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/rocketchat/rocketchat_test.go
================================================
package rocketchat
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
expected: nil,
},
{
name: "valid-with-channel",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
expected: nil,
},
{
name: "invalid-webhook-url",
provider: AlertProvider{DefaultConfig: Config{}},
expected: ErrWebhookURLNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["username"] != "Gatus" {
t.Errorf("expected username to be 'Gatus', got %v", body["username"])
}
attachments := body["attachments"].([]interface{})
if len(attachments) != 1 {
t.Errorf("expected 1 attachment, got %d", len(attachments))
}
attachment := attachments[0].(map[string]interface{})
if attachment["color"] != "#dd0000" {
t.Errorf("expected color to be '#dd0000', got %v", attachment["color"])
}
text := attachment["text"].(string)
if !strings.Contains(text, "failed 3 time(s)") {
t.Errorf("expected text to contain failure count, got %s", text)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "triggered-with-channel",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["channel"] != "#alerts" {
t.Errorf("expected channel to be '#alerts', got %v", body["channel"])
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
attachments := body["attachments"].([]interface{})
attachment := attachments[0].(map[string]interface{})
if attachment["color"] != "#36a64f" {
t.Errorf("expected color to be '#36a64f', got %v", attachment["color"])
}
text := attachment["text"].(string)
if !strings.Contains(text, "resolved") {
t.Errorf("expected text to contain 'resolved', got %s", text)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
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.StatusBadRequest, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
================================================
FILE: alerting/provider/sendgrid/sendgrid.go
================================================
package sendgrid
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
ApiURL = "https://api.sendgrid.com/v3/mail/send"
)
var (
ErrAPIKeyNotSet = errors.New("api-key not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
APIKey string `yaml:"api-key"`
From string `yaml:"from"`
To string `yaml:"to"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
if len(cfg.From) == 0 {
return ErrFromNotSet
}
if len(cfg.To) == 0 {
return ErrToNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using SendGrid
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
payload := provider.buildSendGridPayload(cfg, subject, body)
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
request, err := http.NewRequest(http.MethodPost, ApiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+cfg.APIKey)
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to sendgrid alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type SendGridPayload struct {
Personalizations []Personalization `json:"personalizations"`
From Email `json:"from"`
Subject string `json:"subject"`
Content []Content `json:"content"`
}
type Personalization struct {
To []Email `json:"to"`
}
type Email struct {
Email string `json:"email"`
}
type Content struct {
Type string `json:"type"`
Value string `json:"value"`
}
// buildSendGridPayload builds the SendGrid API payload
func (provider *AlertProvider) buildSendGridPayload(cfg *Config, subject, body string) SendGridPayload {
toEmails := strings.Split(cfg.To, ",")
var recipients []Email
for _, email := range toEmails {
recipients = append(recipients, Email{Email: strings.TrimSpace(email)})
}
return SendGridPayload{
Personalizations: []Personalization{
{
To: recipients,
},
},
From: Email{
Email: cfg.From,
},
Subject: subject,
Content: []Content{
{
Type: "text/plain",
Value: body,
},
{
Type: "text/html",
Value: strings.ReplaceAll(body, "\n", " "),
},
},
}
}
// buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\nCondition results:\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
var extraLabels string
if len(ep.ExtraLabels) > 0 {
extraLabels = "\n\nExtra labels:\n"
for key, value := range ep.ExtraLabels {
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
}
}
return subject, message + description + extraLabels + formattedConditionResults
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/sendgrid/sendgrid_test.go
================================================
package sendgrid
import (
"io"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: "", From: "", To: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{
APIKey: "SG.test",
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "to@example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider with empty Group should not have been valid")
}
if err := providerWithInvalidOverrideGroup.Validate(); err != ErrDuplicateGroupOverride {
t.Error("provider with empty Group should return ErrDuplicateGroupOverride")
}
providerWithDuplicateOverrideGroups := AlertProvider{
DefaultConfig: Config{
APIKey: "SG.test",
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "to1@example.com"},
Group: "group",
},
{
Config: Config{To: "to2@example.com"},
Group: "group",
},
},
}
if err := providerWithDuplicateOverrideGroups.Validate(); err == nil {
t.Error("provider with duplicate group overrides should not have been valid")
}
if err := providerWithDuplicateOverrideGroups.Validate(); err != ErrDuplicateGroupOverride {
t.Error("provider with duplicate group overrides should return ErrDuplicateGroupOverride")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
APIKey: "SG.test",
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "to@example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
providerWithValidMultipleOverrides := AlertProvider{
DefaultConfig: Config{
APIKey: "SG.test",
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "group1@example.com"},
Group: "group1",
},
{
Config: Config{To: "group2@example.com"},
Group: "group2",
},
},
}
if err := providerWithValidMultipleOverrides.Validate(); err != nil {
t.Error("provider with multiple valid overrides should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
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.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
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.StatusBadRequest, Body: io.NopCloser(strings.NewReader(`{"errors": [{"message": "Invalid API key"}]}`))}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildSendGridPayload(t *testing.T) {
provider := &AlertProvider{}
cfg := &Config{
From: "test@example.com",
To: "to1@example.com,to2@example.com",
}
subject := "Test Subject"
body := "Test Body\nWith new line"
payload := provider.buildSendGridPayload(cfg, subject, body)
if payload.Subject != subject {
t.Errorf("expected subject to be %s, got %s", subject, payload.Subject)
}
if payload.From.Email != cfg.From {
t.Errorf("expected from email to be %s, got %s", cfg.From, payload.From.Email)
}
if len(payload.Personalizations) != 1 {
t.Errorf("expected 1 personalization, got %d", len(payload.Personalizations))
}
if len(payload.Personalizations[0].To) != 2 {
t.Errorf("expected 2 recipients, got %d", len(payload.Personalizations[0].To))
}
if payload.Personalizations[0].To[0].Email != "to1@example.com" {
t.Errorf("expected first recipient to be to1@example.com, got %s", payload.Personalizations[0].To[0].Email)
}
if payload.Personalizations[0].To[1].Email != "to2@example.com" {
t.Errorf("expected second recipient to be to2@example.com, got %s", payload.Personalizations[0].To[1].Email)
}
if len(payload.Content) != 2 {
t.Errorf("expected 2 content types, got %d", len(payload.Content))
}
if payload.Content[0].Type != "text/plain" {
t.Errorf("expected first content type to be text/plain, got %s", payload.Content[0].Type)
}
if payload.Content[0].Value != body {
t.Errorf("expected plain text content to be %s, got %s", body, payload.Content[0].Value)
}
if payload.Content[1].Type != "text/html" {
t.Errorf("expected second content type to be text/html, got %s", payload.Content[1].Type)
}
expectedHTML := "Test Body With new line"
if payload.Content[1].Value != expectedHTML {
t.Errorf("expected HTML content to be %s, got %s", expectedHTML, payload.Content[1].Value)
}
}
func TestAlertProvider_buildMessageSubjectAndBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
Endpoint *endpoint.Endpoint
ExpectedSubject string
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-no-extra-labels",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if subject != scenario.ExpectedSubject {
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
}
if body != scenario.ExpectedBody {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "to01@example.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "group-to@example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "SG.override", "to": "alert-to@example.com", "from": "alert-from@example.com"}},
ExpectedOutput: Config{APIKey: "SG.override", From: "alert-from@example.com", To: "alert-to@example.com"},
},
{
Name: "provider-with-multiple-overrides-pick-correct-group",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
Overrides: []Override{
{
Group: "group1",
Config: Config{APIKey: "SG.group1", To: "group1@example.com"},
},
{
Group: "group2",
Config: Config{APIKey: "SG.group2", From: "group2@example.com"},
},
},
},
InputGroup: "group2",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.group2", From: "group2@example.com", To: "default@example.com"},
},
{
Name: "provider-partial-override-hierarchy",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
Overrides: []Override{
{
Group: "test-group",
Config: Config{From: "group@example.com"},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert@example.com"}},
ExpectedOutput: Config{APIKey: "SG.default", From: "group@example.com", To: "alert@example.com"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.APIKey != scenario.ExpectedOutput.APIKey {
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if got.To != scenario.ExpectedOutput.To {
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
// 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)
}
})
}
}
func TestConfig_Validate(t *testing.T) {
scenarios := []struct {
Name string
Config Config
ExpectedError error
}{
{
Name: "missing-api-key",
Config: Config{APIKey: "", From: "test@example.com", To: "to@example.com"},
ExpectedError: ErrAPIKeyNotSet,
},
{
Name: "missing-from",
Config: Config{APIKey: "SG.test", From: "", To: "to@example.com"},
ExpectedError: ErrFromNotSet,
},
{
Name: "missing-to",
Config: Config{APIKey: "SG.test", From: "test@example.com", To: ""},
ExpectedError: ErrToNotSet,
},
{
Name: "valid-config",
Config: Config{APIKey: "SG.test", From: "test@example.com", To: "to@example.com"},
ExpectedError: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Config.Validate()
if scenario.ExpectedError == nil && err != nil {
t.Errorf("expected no error, got %v", err)
}
if scenario.ExpectedError != nil && err == nil {
t.Errorf("expected error %v, got none", scenario.ExpectedError)
}
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestConfig_Merge(t *testing.T) {
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
override := Config{APIKey: "SG.override", To: "override@example.com"}
config.Merge(&override)
if config.APIKey != "SG.override" {
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
}
if config.From != "from@example.com" {
t.Errorf("expected From to remain from@example.com, got %s", config.From)
}
if config.To != "override@example.com" {
t.Errorf("expected To to be override@example.com, got %s", config.To)
}
}
func TestConfig_MergeWithClientConfig(t *testing.T) {
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
override := Config{APIKey: "SG.override", ClientConfig: &client.Config{Timeout: 30000}}
config.Merge(&override)
if config.APIKey != "SG.override" {
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
}
if config.ClientConfig == nil {
t.Error("expected ClientConfig to be set")
}
if config.ClientConfig.Timeout != 30000 {
t.Errorf("expected ClientConfig.Timeout to be 30000, got %d", config.ClientConfig.Timeout)
}
config2 := Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com", ClientConfig: &client.Config{Timeout: 10000}}
override2 := Config{APIKey: "SG.override2"}
config2.Merge(&override2)
if config2.ClientConfig.Timeout != 10000 {
t.Errorf("expected ClientConfig.Timeout to remain 10000, got %d", config2.ClientConfig.Timeout)
}
}
================================================
FILE: alerting/provider/signal/signal.go
================================================
package signal
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrApiURLNotSet = errors.New("api-url not set")
ErrNumberNotSet = errors.New("number not set")
ErrRecipientsNotSet = errors.New("recipients not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
ApiURL string `yaml:"api-url"` // Signal API URL (e.g., signal-cli-rest-api instance)
Number string `yaml:"number"` // Sender phone number
Recipients []string `yaml:"recipients"` // List of recipient phone numbers
}
func (cfg *Config) Validate() error {
if len(cfg.ApiURL) == 0 {
return ErrApiURLNotSet
}
if !strings.HasSuffix(cfg.ApiURL, "/v2/send") {
cfg.ApiURL = cfg.ApiURL + "/v2/send"
}
if len(cfg.Number) == 0 {
return ErrNumberNotSet
}
if len(cfg.Recipients) == 0 {
return ErrRecipientsNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ApiURL) > 0 {
cfg.ApiURL = override.ApiURL
}
if len(override.Number) > 0 {
cfg.Number = override.Number
}
if len(override.Recipients) > 0 {
cfg.Recipients = override.Recipients
}
}
// AlertProvider is the configuration necessary for sending an alert using Signal
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
for _, recipient := range cfg.Recipients {
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved, recipient)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, cfg.ApiURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
response.Body.Close()
return fmt.Errorf("call to signal alert returned status code %d: %s", response.StatusCode, string(body))
}
response.Body.Close()
}
return nil
}
type Body struct {
Message string `json:"message"`
Number string `json:"number"`
Recipients []string `json:"recipients"`
}
// 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, recipient string) ([]byte, error) {
var message string
if resolved {
message = fmt.Sprintf("🟢 RESOLVED: %s\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("🔴 ALERT: %s\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
message += "\n\nCondition results:"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
}
}
body := Body{
Message: message,
Number: cfg.Number,
Recipients: []string{recipient},
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/signal/signal_test.go
================================================
package signal
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
expected: nil,
},
{
name: "invalid-api-url",
provider: AlertProvider{DefaultConfig: Config{Number: "+1234567890", Recipients: []string{"+0987654321"}}},
expected: ErrApiURLNotSet,
},
{
name: "invalid-number",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Recipients: []string{"+0987654321"}}},
expected: ErrNumberNotSet,
},
{
name: "invalid-recipients",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890"}},
expected: ErrRecipientsNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321", "+1111111111"}}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.URL.Path != "/v2/send" {
t.Errorf("expected path /v2/send, got %s", r.URL.Path)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["number"] != "+1234567890" {
t.Errorf("expected number to be '+1234567890', got %v", body["number"])
}
recipients := body["recipients"].([]interface{})
if len(recipients) != 1 {
t.Errorf("expected 1 recipient per request, got %d", len(recipients))
}
message := body["message"].(string)
if !strings.Contains(message, "ALERT") {
t.Errorf("expected message to contain 'ALERT', got %s", message)
}
if !strings.Contains(message, "failed 3 time(s)") {
t.Errorf("expected message to contain failure count, got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
message := body["message"].(string)
if !strings.Contains(message, "RESOLVED") {
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
================================================
FILE: alerting/provider/signl4/signl4.go
================================================
package signl4
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrTeamSecretNotSet = errors.New("team-secret not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
TeamSecret string `yaml:"team-secret"` // SIGNL4 team secret
}
func (cfg *Config) Validate() error {
if len(cfg.TeamSecret) == 0 {
return ErrTeamSecretNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.TeamSecret) > 0 {
cfg.TeamSecret = override.TeamSecret
}
}
// AlertProvider is the configuration necessary for sending an alert using SIGNL4
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
webhookURL := fmt.Sprintf("https://connect.signl4.com/webhook/%s", cfg.TeamSecret)
request, err := http.NewRequest(http.MethodPost, webhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to signl4 alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Title string `json:"Title"`
Message string `json:"Message"`
XS4Service string `json:"X-S4-Service"`
XS4Status string `json:"X-S4-Status"`
XS4ExternalID string `json:"X-S4-ExternalID"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var title, message, status string
if resolved {
title = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
status = "resolved"
} else {
title = fmt.Sprintf("TRIGGERED: %s", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
status = "new"
}
var conditionResults string
if len(result.ConditionResults) > 0 {
conditionResults = "\n\nCondition results:\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✓"
} else {
prefix = "✗"
}
conditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += "\n\nDescription: " + alertDescription
}
message += conditionResults
body := Body{
Title: title,
Message: message,
XS4Service: ep.DisplayName(),
XS4Status: status,
XS4ExternalID: fmt.Sprintf("gatus-%s", ep.Key()),
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/signl4/signl4_test.go
================================================
package signl4
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{TeamSecret: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{TeamSecret: "team-secret-123"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{TeamSecret: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider team secret shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Config: Config{TeamSecret: "team-secret-override"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint endpoint.Endpoint
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
},
{
Name: "triggered-with-group",
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"Title\":\"TRIGGERED: group/name\",\"Message\":\"An alert for group/name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"Title\":\"RESOLVED: name\",\"Message\":\"An alert for name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-_name\"}",
},
{
Name: "resolved-with-group",
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"Title\":\"RESOLVED: group/name\",\"Message\":\"An alert for group/name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body, err := scenario.Provider.buildRequestBody(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: conditionResults,
},
scenario.Resolved,
)
if err != nil {
t.Fatalf("buildRequestBody returned an error: %v", err)
}
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Group: "group",
Config: Config{TeamSecret: "team-secret-override"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Group: "group",
Config: Config{TeamSecret: "team-secret-override"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{TeamSecret: "team-secret-override"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Group: "group",
Config: Config{TeamSecret: "team-secret-override"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"team-secret": "team-secret-alert"}},
ExpectedOutput: Config{TeamSecret: "team-secret-alert"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.TeamSecret != scenario.ExpectedOutput.TeamSecret {
t.Errorf("expected team secret to be %s, got %s", scenario.ExpectedOutput.TeamSecret, got.TeamSecret)
}
// 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)
}
})
}
}
func TestAlertProvider_GetConfigWithInvalidAlertOverride(t *testing.T) {
// Test case 1: Empty override should be ignored, default config should be used
provider := AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
}
alertWithEmptyOverride := alert.Alert{
ProviderOverride: map[string]any{"team-secret": ""},
}
cfg, err := provider.GetConfig("", &alertWithEmptyOverride)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if cfg.TeamSecret != "team-secret-123" {
t.Errorf("expected team secret to remain default 'team-secret-123', got %s", cfg.TeamSecret)
}
// Test case 2: Invalid default config with no valid override should fail
providerWithInvalidDefault := AlertProvider{
DefaultConfig: Config{TeamSecret: ""},
}
alertWithEmptyOverride2 := alert.Alert{
ProviderOverride: map[string]any{"team-secret": ""},
}
_, err = providerWithInvalidDefault.GetConfig("", &alertWithEmptyOverride2)
if err == nil {
t.Error("expected error due to invalid default config, got none")
}
if err != ErrTeamSecretNotSet {
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
}
}
func TestAlertProvider_ValidateWithDuplicateGroupOverride(t *testing.T) {
providerWithDuplicateOverride := AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Group: "group1",
Config: Config{TeamSecret: "team-secret-override-1"},
},
{
Group: "group1",
Config: Config{TeamSecret: "team-secret-override-2"},
},
},
}
if err := providerWithDuplicateOverride.Validate(); err == nil {
t.Error("provider should not have been valid due to duplicate group override")
}
if err := providerWithDuplicateOverride.Validate(); err != ErrDuplicateGroupOverride {
t.Errorf("expected ErrDuplicateGroupOverride, got %v", providerWithDuplicateOverride.Validate())
}
}
func TestAlertProvider_ValidateOverridesWithInvalidAlert(t *testing.T) {
provider := AlertProvider{
DefaultConfig: Config{TeamSecret: ""},
}
alertWithEmptyOverride := alert.Alert{
ProviderOverride: map[string]any{"team-secret": ""},
}
err := provider.ValidateOverrides("", &alertWithEmptyOverride)
if err == nil {
t.Error("expected error due to invalid default config, got none")
}
if err != ErrTeamSecretNotSet {
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
}
}
================================================
FILE: alerting/provider/slack/slack.go
================================================
package slack
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
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 {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
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
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Text string `json:"text"`
Attachments []Attachment `json:"attachments"`
}
type Attachment struct {
Title string `json:"title"`
Text string `json:"text"`
Short bool `json:"short"`
Color string `json:"color"`
Fields []Field `json:"fields,omitempty"`
}
type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
// 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 {
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)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000"
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":white_check_mark:"
} else {
prefix = ":x:"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
body := Body{
Text: "",
Attachments: []Attachment{
{
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",
Value: formattedConditionResults,
Short: false,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/slack/slack_test.go
================================================
package slack
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint endpoint.Endpoint
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "triggered-with-group",
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,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\"}]}",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved-with-group",
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) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{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{
ConditionResults: conditionResults,
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.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 {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/splunk/splunk.go
================================================
package splunk
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrHecURLNotSet = errors.New("hec-url not set")
ErrHecTokenNotSet = errors.New("hec-token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
HecURL string `yaml:"hec-url"` // Splunk HEC (HTTP Event Collector) URL
HecToken string `yaml:"hec-token"` // Splunk HEC token
Source string `yaml:"source,omitempty"` // Event source
SourceType string `yaml:"sourcetype,omitempty"` // Event source type
Index string `yaml:"index,omitempty"` // Splunk index
}
func (cfg *Config) Validate() error {
if len(cfg.HecURL) == 0 {
return ErrHecURLNotSet
}
if len(cfg.HecToken) == 0 {
return ErrHecTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.HecURL) > 0 {
cfg.HecURL = override.HecURL
}
if len(override.HecToken) > 0 {
cfg.HecToken = override.HecToken
}
if len(override.Source) > 0 {
cfg.Source = override.Source
}
if len(override.SourceType) > 0 {
cfg.SourceType = override.SourceType
}
if len(override.Index) > 0 {
cfg.Index = override.Index
}
}
// AlertProvider is the configuration necessary for sending an alert using Splunk
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/services/collector/event", cfg.HecURL), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("Splunk %s", cfg.HecToken))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to splunk alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Time int64 `json:"time"`
Source string `json:"source,omitempty"`
SourceType string `json:"sourcetype,omitempty"`
Index string `json:"index,omitempty"`
Event Event `json:"event"`
}
type Event struct {
AlertType string `json:"alert_type"`
Endpoint string `json:"endpoint"`
Group string `json:"group,omitempty"`
Status string `json:"status"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
Conditions []*endpoint.ConditionResult `json:"conditions,omitempty"`
}
// 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, error) {
var alertType, status, message string
if resolved {
alertType = "resolved"
status = "ok"
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
alertType = "triggered"
status = "critical"
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
event := Event{
AlertType: alertType,
Endpoint: ep.DisplayName(),
Group: ep.Group,
Status: status,
Message: message,
Description: alert.GetDescription(),
}
if len(result.ConditionResults) > 0 {
event.Conditions = result.ConditionResults
}
body := Body{
Time: time.Now().Unix(),
Event: event,
}
// Set optional fields
if cfg.Source != "" {
body.Source = cfg.Source
} else {
body.Source = "gatus"
}
if cfg.SourceType != "" {
body.SourceType = cfg.SourceType
} else {
body.SourceType = "gatus:alert"
}
if cfg.Index != "" {
body.Index = cfg.Index
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/splunk/splunk_test.go
================================================
package splunk
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
expected: nil,
},
{
name: "valid-with-index",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
expected: nil,
},
{
name: "invalid-hec-url",
provider: AlertProvider{DefaultConfig: Config{HecToken: "token123"}},
expected: ErrHecURLNotSet,
},
{
name: "invalid-hec-token",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088"}},
expected: ErrHecTokenNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.URL.Path != "/services/collector/event" {
t.Errorf("expected path /services/collector/event, got %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Splunk token123" {
t.Errorf("expected Authorization header to be 'Splunk token123', got %s", r.Header.Get("Authorization"))
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["time"] == nil {
t.Error("expected 'time' field in request body")
}
event := body["event"].(map[string]interface{})
if event["alert_type"] != "triggered" {
t.Errorf("expected alert_type to be 'triggered', got %v", event["alert_type"])
}
if event["status"] != "critical" {
t.Errorf("expected status to be 'critical', got %v", event["status"])
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["index"] != "main" {
t.Errorf("expected index to be 'main', got %v", body["index"])
}
event := body["event"].(map[string]interface{})
if event["alert_type"] != "resolved" {
t.Errorf("expected alert_type to be 'resolved', got %v", event["alert_type"])
}
if event["status"] != "ok" {
t.Errorf("expected status to be 'ok', got %v", event["status"])
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
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.StatusForbidden, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
================================================
FILE: alerting/provider/squadcast/squadcast.go
================================================
package squadcast
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Squadcast webhook URL
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// AlertProvider is the configuration necessary for sending an alert using Squadcast
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to squadcast alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Message string `json:"message"`
Description string `json:"description,omitempty"`
EventID string `json:"event_id"`
Status string `json:"status"`
Tags map[string]string `json:"tags,omitempty"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var message, status string
eventID := fmt.Sprintf("gatus-%s", ep.Key())
if resolved {
message = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
status = "resolve"
} else {
message = fmt.Sprintf("ALERT: %s", ep.DisplayName())
status = "trigger"
}
description := fmt.Sprintf("Endpoint: %s\n", ep.DisplayName())
if resolved {
description += fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row\n", alert.SuccessThreshold)
} else {
description += fmt.Sprintf("Endpoint has failed %d time(s) in a row\n", alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description += fmt.Sprintf("\nDescription: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
description += "\n\nCondition Results:"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
description += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
}
}
body := Body{
Message: message,
Description: description,
EventID: eventID,
Status: status,
Tags: map[string]string{
"endpoint": ep.Name,
"group": ep.Group,
"source": "gatus",
},
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/squadcast/squadcast_test.go
================================================
package squadcast
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
expected: nil,
},
{
name: "invalid-webhook-url",
provider: AlertProvider{DefaultConfig: Config{}},
expected: ErrWebhookURLNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["status"] != "trigger" {
t.Errorf("expected status to be 'trigger', got %v", body["status"])
}
if body["event_id"] == nil {
t.Error("expected 'event_id' field in request body")
}
message := body["message"].(string)
if !strings.Contains(message, "ALERT") {
t.Errorf("expected message to contain 'ALERT', got %s", message)
}
description := body["description"].(string)
if !strings.Contains(description, "failed 3 time(s)") {
t.Errorf("expected description to contain failure count, got %s", description)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["status"] != "resolve" {
t.Errorf("expected status to be 'resolve', got %v", body["status"])
}
message := body["message"].(string)
if !strings.Contains(message, "RESOLVED") {
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
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.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
================================================
FILE: alerting/provider/teams/teams.go
================================================
package teams
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
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 Teams
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Type string `json:"@type"`
Context string `json:"@context"`
ThemeColor string `json:"themeColor"`
Title string `json:"title"`
Text string `json:"text"`
Sections []Section `json:"sections,omitempty"`
}
type Section struct {
ActivityTitle string `json:"activityTitle"`
Text string `json:"text"`
}
// 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 {
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)
color = "#36A64F"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#DD0000"
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s - `%s` ", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ": " + alertDescription
}
body := Body{
Type: "MessageCard",
Context: "http://schema.org/extensions",
ThemeColor: color,
Title: cfg.Title,
Text: message + description,
}
if len(body.Title) == 0 {
body.Title = "🚨 Gatus"
}
if len(formattedConditionResults) > 0 {
body.Sections = append(body.Sections, Section{
ActivityTitle: "Condition results",
Text: formattedConditionResults,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/teams/teams_test.go
================================================
package teams
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#DD0000\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row: description-1\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x274C; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x274C; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x2705; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x2705; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
},
{
Name: "resolved-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.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 {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/teamsworkflows/teamsworkflows.go
================================================
package teamsworkflows
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
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.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
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 Teams
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// AdaptiveCardBody represents the structure of an Adaptive Card
type AdaptiveCardBody struct {
Type string `json:"type"`
Version string `json:"version"`
Body []CardBody `json:"body"`
MSTeams MSTeamsBody `json:"msteams"`
}
// CardBody represents the body of the Adaptive Card
type CardBody struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Wrap bool `json:"wrap"`
Separator bool `json:"separator,omitempty"`
Size string `json:"size,omitempty"`
Weight string `json:"weight,omitempty"`
Items []CardBody `json:"items,omitempty"`
Facts []Fact `json:"facts,omitempty"`
FactSet *FactSetBody `json:"factSet,omitempty"`
Style string `json:"style,omitempty"`
}
// MSTeamsBody represents the msteams options
type MSTeamsBody struct {
Width string `json:"width"`
}
// FactSetBody represents the FactSet in the Adaptive Card
type FactSetBody struct {
Type string `json:"type"`
Facts []Fact `json:"facts"`
}
// Fact represents an individual fact in the FactSet
type Fact struct {
Title string `json:"title"`
Value string `json:"value"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string
var themeColor 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)
themeColor = "Good" // green
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row.", ep.DisplayName(), alert.FailureThreshold)
themeColor = "Attention" // red
}
// Configure default title if it's not provided
title := "⛑️ Gatus"
if cfg.Title != "" {
title = cfg.Title
}
// Build the facts from the condition results
var facts []Fact
for _, conditionResult := range result.ConditionResults {
var key string
if conditionResult.Success {
key = "✅"
} else {
key = "❌"
}
facts = append(facts, Fact{
Title: key,
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
Body: []CardBody{
{
Type: "Container",
Style: themeColor,
Items: []CardBody{
{
Type: "Container",
Style: "Default",
Items: []CardBody{
{
Type: "TextBlock",
Text: title,
Size: "Medium",
Weight: "Bolder",
},
{
Type: "TextBlock",
Text: message,
Wrap: true,
},
{
Type: "TextBlock",
Text: description,
Wrap: true,
},
{
Type: "FactSet",
Facts: facts,
},
},
},
},
},
},
MSTeams: MSTeamsBody{
Width: "Full",
},
}
attachment := map[string]interface{}{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": cardContent,
}
payload := map[string]interface{}{
"type": "message",
"attachments": []interface{}{attachment},
}
bodyAsJSON, _ := json.Marshal(payload)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/teamsworkflows/teamsworkflows_test.go
================================================
package teamsworkflows
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid, got", err.Error())
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
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 {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
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 {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"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\":\"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",
NoConditions: true,
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"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 {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.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 {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/telegram/telegram.go
================================================
package telegram
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const ApiURL = "https://api.telegram.org"
var (
ErrTokenNotSet = errors.New("token not set")
ErrIDNotSet = errors.New("id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Token string `yaml:"token"`
ID string `yaml:"id"`
TopicID string `yaml:"topic-id,omitempty"`
ApiUrl string `yaml:"api-url"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.ApiUrl) == 0 {
cfg.ApiUrl = ApiURL
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
if len(cfg.ID) == 0 {
return ErrIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.ID) > 0 {
cfg.ID = override.ID
}
if len(override.TopicID) > 0 {
cfg.TopicID = override.TopicID
}
if len(override.ApiUrl) > 0 {
cfg.ApiUrl = override.ApiUrl
}
}
// AlertProvider is the configuration necessary for sending an alert using Telegram
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 overrides that may be prioritized over the default configuration
Overrides []*Override `yaml:"overrides,omitempty"`
}
// Override is a configuration that may be prioritized over the default configuration
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", cfg.ApiUrl, cfg.Token), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode"`
TopicID string `json:"message_thread_id,omitempty"`
}
// 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 {
var message string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n*Condition results*\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
}
var text string
if len(alert.GetDescription()) > 0 {
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)
}
bodyAsJSON, _ := json.Marshal(Body{
ChatID: cfg.ID,
Text: text,
ParseMode: "MARKDOWN",
TopicID: cfg.TopicID,
})
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/telegram/telegram_test.go
================================================
package telegram
import (
"encoding/json"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "", ID: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
})
t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Config: Config{Token: "token", ID: "id"}}}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group1", Config: Config{Token: "token", ID: "id"}}, {Group: "group1", Config: Config{ID: "id2"}}}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
})
t.Run("valid-provider-with-overrides", func(t *testing.T) {
validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "token", ID: "id"}}}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
})
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
descriptionWithLink := "[link](https://example.org/)"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
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* \\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* \\ndescription-2 \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "resolved-with-no-conditions",
NoConditions: true,
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* \\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* \\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 {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
t.Run("get-token-with-override", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken", ID: "overrideID"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "groupToken" {
t.Error("token should have been 'groupToken'")
}
if cfg.ID != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{ID: "overrideID"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != provider.DefaultConfig.Token {
t.Error("token should have been the default token")
}
if cfg.ID != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "groupToken" {
t.Error("token should have been 'groupToken'")
}
if cfg.ID != provider.DefaultConfig.ID {
t.Error("id should have been the default id")
}
})
t.Run("get-default-token-with-overridden-token-and-alert-token-override", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
alert := &alert.Alert{ProviderOverride: map[string]any{"token": "alertToken"}}
cfg, err := provider.GetConfig("group", alert)
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "alertToken" {
t.Error("token should have been 'alertToken'")
}
if cfg.ID != provider.DefaultConfig.ID {
t.Error("id should have been the default id")
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = provider.ValidateOverrides("group", alert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
================================================
FILE: alerting/provider/twilio/twilio.go
================================================
package twilio
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrSIDNotSet = errors.New("sid not set")
ErrTokenNotSet = errors.New("token not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
)
type Config struct {
SID string `yaml:"sid"`
Token string `yaml:"token"`
From string `yaml:"from"`
To string `yaml:"to"`
// TODO in v6.0.0: Rename this to text-triggered
TextTwilioTriggered string `yaml:"text-twilio-triggered,omitempty"` // String used in the SMS body and subject (optional)
// TODO in v6.0.0: Rename this to text-resolved
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
}
func (cfg *Config) Validate() error {
if len(cfg.SID) == 0 {
return ErrSIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
if len(cfg.From) == 0 {
return ErrFromNotSet
}
if len(cfg.To) == 0 {
return ErrToNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.SID) > 0 {
cfg.SID = override.SID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
if len(override.TextTwilioTriggered) > 0 {
cfg.TextTwilioTriggered = override.TextTwilioTriggered
}
if len(override.TextTwilioResolved) > 0 {
cfg.TextTwilioResolved = override.TextTwilioResolved
}
}
// AlertProvider is the configuration necessary for sending an alert using Twilio
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", cfg.SID), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.SID+":"+cfg.Token))))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// 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) string {
var message string
if resolved {
if len(cfg.TextTwilioResolved) > 0 {
// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
message = cfg.TextTwilioResolved
message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
} else {
if len(cfg.TextTwilioTriggered) > 0 {
// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
message = cfg.TextTwilioTriggered
message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
}
return url.Values{
"To": {cfg.To},
"From": {cfg.From},
"Body": {message},
}.Encode()
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/twilio/twilio_test.go
================================================
package twilio
import (
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestTwilioAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
DefaultConfig: Config{
SID: "1",
Token: "1",
From: "1",
To: "1",
},
}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
},
{
Name: "triggered-with-old-placeholders",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: {endpoint} - {description}"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
},
{
Name: "triggered-with-new-placeholders",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: [ENDPOINT] - [ALERT_DESCRIPTION]"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
},
{
Name: "resolved-with-mixed-placeholders",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioResolved: "Resolved: {endpoint} and [ENDPOINT] - {description} and [ALERT_DESCRIPTION]"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "Body=Resolved%3A+endpoint-name+and+endpoint-name+-+description-2+and+description-2&From=3&To=4",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"sid": "5", "token": "6", "from": "7", "to": "8"}},
ExpectedOutput: Config{SID: "5", Token: "6", From: "7", To: "8"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.SID != scenario.ExpectedOutput.SID {
t.Errorf("expected SID to be %s, got %s", scenario.ExpectedOutput.SID, got.SID)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected from to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if got.To != scenario.ExpectedOutput.To {
t.Errorf("expected to to be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
================================================
FILE: alerting/provider/vonage/vonage.go
================================================
package vonage
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const ApiURL = "https://rest.nexmo.com/sms/json"
var (
ErrAPIKeyNotSet = errors.New("api-key not set")
ErrAPISecretNotSet = errors.New("api-secret not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
APIKey string `yaml:"api-key"`
APISecret string `yaml:"api-secret"`
From string `yaml:"from"`
To []string `yaml:"to"`
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
if len(cfg.APISecret) == 0 {
return ErrAPISecretNotSet
}
if len(cfg.From) == 0 {
return ErrFromNotSet
}
if len(cfg.To) == 0 {
return ErrToNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.APISecret) > 0 {
cfg.APISecret = override.APISecret
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using Vonage
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
message := provider.buildMessage(cfg, ep, alert, result, resolved)
// Send SMS to each recipient
for _, recipient := range cfg.To {
if err := provider.sendSMS(cfg, recipient, message); err != nil {
return err
}
}
return nil
}
// sendSMS sends an individual SMS message
func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
data := url.Values{}
data.Set("api_key", cfg.APIKey)
data.Set("api_secret", cfg.APISecret)
data.Set("from", cfg.From)
data.Set("to", to)
data.Set("text", message)
request, err := http.NewRequest(http.MethodPost, ApiURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
// Read response body once and use it for both error handling and JSON processing
body, err := io.ReadAll(response.Body)
if err != nil {
return err
}
if response.StatusCode >= 400 {
return fmt.Errorf("call to vonage alert returned status code %d: %s", response.StatusCode, string(body))
}
// Check response for errors in messages array
var vonageResponse Response
if err := json.Unmarshal(body, &vonageResponse); err != nil {
return err
}
// Check if any message failed
for _, msg := range vonageResponse.Messages {
if msg.Status != "0" {
return fmt.Errorf("vonage SMS failed with status %s: %s", msg.Status, msg.ErrorText)
}
}
return nil
}
type Response struct {
MessageCount string `json:"message-count"`
Messages []Message `json:"messages"`
}
type Message struct {
To string `json:"to"`
MessageID string `json:"message-id"`
Status string `json:"status"`
ErrorText string `json:"error-text"`
RemainingBalance string `json:"remaining-balance"`
MessagePrice string `json:"message-price"`
Network string `json:"network"`
}
// buildMessage builds the SMS message content
func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
if resolved {
return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else {
return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/vonage/vonage_test.go
================================================
package vonage
import (
"io"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestVonageAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestVonageAlertProvider_IsValidWithOverride(t *testing.T) {
validProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
APIKey: "override-key",
APISecret: "override-secret",
From: "Override",
To: []string{"+9876543210"},
},
},
},
}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestVonageAlertProvider_IsNotValidWithInvalidOverrideGroup(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "",
Config: Config{
APIKey: "override-key",
APISecret: "override-secret",
From: "Override",
To: []string{"+9876543210"},
},
},
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
}
func TestVonageAlertProvider_IsNotValidWithDuplicateOverrideGroup(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
APIKey: "override-key1",
APISecret: "override-secret1",
From: "Override1",
To: []string{"+9876543210"},
},
},
{
Group: "test-group",
Config: Config{
APIKey: "override-key2",
APISecret: "override-secret2",
From: "Override2",
To: []string{"+1234567890"},
},
},
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
}
func TestVonageAlertProvider_IsValidWithInvalidFrom(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "",
To: []string{"+1234567890"},
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
}
func TestVonageAlertProvider_IsValidWithInvalidTo(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{},
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
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: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.50","message-price":"0.10","network":"12345"}]}`)),
}
}),
ExpectedError: false,
},
{
Name: "triggered-error-status-code",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "triggered-error-vonage-response",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
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: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"","status":"2","error-text":"Missing from param"}]}`)),
}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.40","message-price":"0.10","network":"12345"}]}`)),
}
}),
ExpectedError: false,
},
{
Name: "multiple-recipients",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890", "+0987654321"},
},
},
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: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.30","message-price":"0.10","network":"12345"}]}`)),
}
}),
ExpectedError: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildMessage(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedMessage string
}{
{
Name: "triggered",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
},
{
Name: "resolved",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedMessage: "RESOLVED: endpoint-name - description-2",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
message := scenario.Provider.buildMessage(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if message != scenario.ExpectedMessage {
t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
{
Name: "provider-with-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
APIKey: "group-override-key",
APISecret: "group-override-secret",
From: "GroupOverride",
To: []string{"+9876543210"},
},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
APIKey: "group-override-key",
APISecret: "group-override-secret",
From: "GroupOverride",
To: []string{"+9876543210"},
},
},
{
Name: "provider-with-group-override-partial",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
To: []string{"+9876543210"},
},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+9876543210"},
},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"api-key": "override-key",
"api-secret": "override-secret",
"from": "Override",
"to": []string{"+9876543210"},
}},
ExpectedOutput: Config{
APIKey: "override-key",
APISecret: "override-secret",
From: "Override",
To: []string{"+9876543210"},
},
},
{
Name: "provider-with-both-group-and-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
APIKey: "group-override-key",
From: "GroupOverride",
},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"api-secret": "alert-override-secret",
"to": []string{"+9876543210"},
}},
ExpectedOutput: Config{
APIKey: "group-override-key",
APISecret: "alert-override-secret",
From: "GroupOverride",
To: []string{"+9876543210"},
},
},
{
Name: "provider-with-group-override-no-match",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "different-group",
Config: Config{
APIKey: "group-override-key",
},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.APIKey != scenario.ExpectedOutput.APIKey {
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
}
if got.APISecret != scenario.ExpectedOutput.APISecret {
t.Errorf("expected APISecret to be %s, got %s", scenario.ExpectedOutput.APISecret, got.APISecret)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if len(got.To) != len(scenario.ExpectedOutput.To) {
t.Errorf("expected To to have length %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
} else {
for i, to := range got.To {
if to != scenario.ExpectedOutput.To[i] {
t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
}
}
}
// 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)
}
})
}
}
================================================
FILE: alerting/provider/webex/webex.go
================================================
package webex
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Webex Teams webhook URL
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// AlertProvider is the configuration necessary for sending an alert using Webex
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to webex alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
RoomID string `json:"roomId,omitempty"`
Text string `json:"text,omitempty"`
Markdown string `json:"markdown"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var message string
if resolved {
message = fmt.Sprintf("✅ **RESOLVED**: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("🚨 **ALERT**: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += fmt.Sprintf("\n\n**Description**: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
message += "\n\n**Condition Results:**"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
message += fmt.Sprintf("\n- %s `%s`", status, conditionResult.Condition)
}
}
body := Body{
Markdown: message,
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/webex/webex_test.go
================================================
package webex
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
expected: nil,
},
{
name: "invalid-webhook-url",
provider: AlertProvider{DefaultConfig: Config{}},
expected: ErrWebhookURLNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["markdown"] == nil {
t.Error("expected 'markdown' field in request body")
}
markdown := body["markdown"].(string)
if !strings.Contains(markdown, "ALERT") {
t.Errorf("expected markdown to contain 'ALERT', got %s", markdown)
}
if !strings.Contains(markdown, "failed 3 time(s)") {
t.Errorf("expected markdown to contain failure count, got %s", markdown)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
markdown := body["markdown"].(string)
if !strings.Contains(markdown, "RESOLVED") {
t.Errorf("expected markdown to contain 'RESOLVED', got %s", markdown)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
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.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
================================================
FILE: alerting/provider/zapier/zapier.go
================================================
package zapier
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Zapier webhook URL
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// AlertProvider is the configuration necessary for sending an alert using Zapier
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to zapier alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
AlertType string `json:"alert_type"`
Status string `json:"status"`
Endpoint string `json:"endpoint"`
Group string `json:"group,omitempty"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
Timestamp string `json:"timestamp"`
SuccessThreshold int `json:"success_threshold,omitempty"`
FailureThreshold int `json:"failure_threshold,omitempty"`
ConditionResults []*endpoint.ConditionResult `json:"condition_results,omitempty"`
TotalConditions int `json:"total_conditions"`
PassedConditions int `json:"passed_conditions"`
FailedConditions int `json:"failed_conditions"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var alertType, status, message string
var successThreshold, failureThreshold int
if resolved {
alertType = "resolved"
status = "ok"
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
successThreshold = alert.SuccessThreshold
} else {
alertType = "triggered"
status = "critical"
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
failureThreshold = alert.FailureThreshold
}
// Process condition results
passedConditions := 0
failedConditions := 0
for _, cr := range result.ConditionResults {
if cr.Success {
passedConditions++
} else {
failedConditions++
}
}
body := Body{
AlertType: alertType,
Status: status,
Endpoint: ep.DisplayName(),
Group: ep.Group,
Message: message,
Description: alert.GetDescription(),
Timestamp: time.Now().Format(time.RFC3339),
SuccessThreshold: successThreshold,
FailureThreshold: failureThreshold,
ConditionResults: result.ConditionResults,
TotalConditions: len(result.ConditionResults),
PassedConditions: passedConditions,
FailedConditions: failedConditions,
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/zapier/zapier_test.go
================================================
package zapier
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
expected: nil,
},
{
name: "invalid-webhook-url",
provider: AlertProvider{DefaultConfig: Config{}},
expected: ErrWebhookURLNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "hooks.zapier.com" {
t.Errorf("expected host hooks.zapier.com, got %s", r.Host)
}
if r.URL.Path != "/hooks/catch/123456/abcdef/" {
t.Errorf("expected path /hooks/catch/123456/abcdef/, got %s", r.URL.Path)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["alert_type"] != "triggered" {
t.Errorf("expected alert_type to be 'triggered', got %v", body["alert_type"])
}
if body["status"] != "critical" {
t.Errorf("expected status to be 'critical', got %v", body["status"])
}
if body["endpoint"] != "endpoint-name" {
t.Errorf("expected endpoint to be 'endpoint-name', got %v", body["endpoint"])
}
message := body["message"].(string)
if !strings.Contains(message, "Alert") {
t.Errorf("expected message to contain 'Alert', got %s", message)
}
if !strings.Contains(message, "failed 3 time(s)") {
t.Errorf("expected message to contain failure count, got %s", message)
}
if body["description"] != firstDescription {
t.Errorf("expected description to be '%s', got %v", firstDescription, body["description"])
}
conditionResults := body["condition_results"].([]interface{})
if len(conditionResults) != 2 {
t.Errorf("expected 2 condition results, got %d", len(conditionResults))
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["alert_type"] != "resolved" {
t.Errorf("expected alert_type to be 'resolved', got %v", body["alert_type"])
}
if body["status"] != "ok" {
t.Errorf("expected status to be 'ok', got %v", body["status"])
}
message := body["message"].(string)
if !strings.Contains(message, "resolved") {
t.Errorf("expected message to contain 'resolved', got %s", message)
}
if body["description"] != secondDescription {
t.Errorf("expected description to be '%s', got %v", secondDescription, body["description"])
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
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.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
err := scenario.provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
{Condition: "[STATUS] == 200", Success: scenario.resolved},
},
},
scenario.resolved,
)
if scenario.expectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.expectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
================================================
FILE: alerting/provider/zulip/zulip.go
================================================
package zulip
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrBotEmailNotSet = errors.New("bot-email not set")
ErrBotAPIKeyNotSet = errors.New("bot-api-key not set")
ErrDomainNotSet = errors.New("domain not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
BotEmail string `yaml:"bot-email"` // Email of the bot user
BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
Domain string `yaml:"domain"` // Domain of the Zulip server
ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
}
func (cfg *Config) Validate() error {
if len(cfg.BotEmail) == 0 {
return ErrBotEmailNotSet
}
if len(cfg.BotAPIKey) == 0 {
return ErrBotAPIKeyNotSet
}
if len(cfg.Domain) == 0 {
return ErrDomainNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.BotEmail) > 0 {
cfg.BotEmail = override.BotEmail
}
if len(override.BotAPIKey) > 0 {
cfg.BotAPIKey = override.BotAPIKey
}
if len(override.Domain) > 0 {
cfg.Domain = override.Domain
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
}
// AlertProvider is the configuration necessary for sending an alert using Zulip
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
buffer := bytes.NewBufferString(provider.buildRequestBody(cfg, ep, alert, result, resolved))
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", cfg.Domain)
request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)
if err != nil {
return err
}
request.SetBasicAuth(cfg.BotEmail, cfg.BotAPIKey)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Gatus")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
// 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) string {
var message string
if resolved {
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += "\n> " + alertDescription + "\n"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":check:"
} else {
prefix = ":cross_mark:"
}
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
}
return url.Values{
"type": {"channel"},
"to": {cfg.ChannelID},
"topic": {"Gatus"},
"content": {message},
}.Encode()
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
================================================
FILE: alerting/provider/zulip/zulip_test.go
================================================
package zulip
import (
"errors"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
AlertProvider AlertProvider
ExpectedError error
}{
{
Name: "Empty provider",
AlertProvider: AlertProvider{},
ExpectedError: ErrBotEmailNotSet,
},
{
Name: "Empty channel id",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
ExpectedError: ErrChannelIDNotSet,
},
{
Name: "Empty domain",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
ExpectedError: ErrDomainNotSet,
},
{
Name: "Empty bot api key",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
ExpectedError: ErrBotAPIKeyNotSet,
},
{
Name: "Empty bot email",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
ExpectedError: ErrBotEmailNotSet,
},
{
Name: "Valid provider",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
ExpectedError: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if err := scenario.AlertProvider.Validate(); !errors.Is(err, scenario.ExpectedError) {
t.Errorf("ExpectedError error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "domain",
ChannelID: "channel-id",
}
alertDesc := "Description"
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
Description: &alertDesc,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
hasConditions bool
expectedBody url.Values
}{
{
name: "Resolved alert with no conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Resolved alert with conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description
:check: - ` + "`[CONNECTED] == true`" + `
:check: - ` + "`[STATUS] == 200`" + `
:check: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with no conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
:cross_mark: - ` + "`[CONNECTED] == true`" + `
:cross_mark: - ` + "`[STATUS] == 200`" + `
:cross_mark: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if tc.hasConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
{Condition: "[BODY] != \"\"", Success: tc.resolved},
}
}
body := tc.provider.buildRequestBody(
&tc.provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: conditionResults,
},
tc.resolved,
)
valuesResult, err := url.ParseQuery(body)
if err != nil {
t.Error(err)
}
if fmt.Sprintf("%v", valuesResult) != fmt.Sprintf("%v", tc.expectedBody) {
t.Errorf("Expected body:\n%v\ngot:\n%v", tc.expectedBody, valuesResult)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("ExpectedError default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("ExpectedError default alert to be nil")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
validateRequest := func(req *http.Request) {
if req.URL.String() != "https://custom-domain/api/v1/messages" {
t.Errorf("ExpectedError url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
}
if req.Method != http.MethodPost {
t.Errorf("ExpectedError POST request, got %s", req.Method)
}
if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("ExpectedError Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type"))
}
if req.Header.Get("User-Agent") != "Gatus" {
t.Errorf("ExpectedError User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
}
}
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "custom-domain",
ChannelID: "channel-id",
}
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "resolved",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "resolved error",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
{
name: "triggered",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "triggered error",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
err := tc.provider.Send(
&endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
},
},
tc.resolved,
)
if tc.expectedError && err == nil {
t.Error("ExpectedError error, got none")
}
if !tc.expectedError && err != nil {
t.Errorf("ExpectedError no error, got: %v", err)
}
})
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-overrides",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel-id"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"bot-email": "alert-bot-email",
"bot-api-key": "alert-bot-api-key",
"domain": "alert-domain",
"channel-id": "alert-channel-id",
}},
ExpectedOutput: Config{
BotEmail: "alert-bot-email",
BotAPIKey: "alert-bot-api-key",
Domain: "alert-domain",
ChannelID: "alert-channel-id",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.BotEmail != scenario.ExpectedOutput.BotEmail {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotEmail, got.BotEmail)
}
if got.BotAPIKey != scenario.ExpectedOutput.BotAPIKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotAPIKey, got.BotAPIKey)
}
if got.Domain != scenario.ExpectedOutput.Domain {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Domain, got.Domain)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
// 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)
}
})
}
}
================================================
FILE: api/api.go
================================================
package api
import (
"io/fs"
"net/http"
"os"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web"
static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/health"
"github.com/TwiN/logr"
fiber "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/cors"
fiberfs "github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/middleware/redirect"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
type API struct {
router *fiber.App
}
func New(cfg *config.Config) *API {
api := &API{}
if cfg.Web == nil {
logr.Warnf("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration")
cfg.Web = web.GetDefaultConfig()
}
if cfg.UI == nil {
logr.Warnf("[api.New] nil ui config passed as parameter. This should only happen in tests. Using default ui configuration")
cfg.UI = ui.GetDefaultConfig()
}
api.router = api.createRouter(cfg)
return api
}
func (a *API) Router() *fiber.App {
return a.router
}
func (a *API) createRouter(cfg *config.Config) *fiber.App {
app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error {
logr.Errorf("[api.ErrorHandler] %s", err.Error())
return fiber.DefaultErrorHandler(c, err)
},
ReadBufferSize: cfg.Web.ReadBufferSize,
Network: fiber.NetworkTCP,
Immutable: true, // If not enabled, will cause issues due to fiber's zero allocation. See #1268 and https://docs.gofiber.io/#zero-allocation
})
if os.Getenv("ENVIRONMENT") == "dev" {
app.Use(cors.New(cors.Config{
AllowOrigins: "http://localhost:8081",
AllowCredentials: true,
}))
}
// Middlewares
app.Use(recover.New())
app.Use(compress.New())
// Define metrics handler, if necessary
if cfg.Metrics {
metricsHandler := promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
DisableCompression: true,
}))
app.Get("/metrics", adaptor.HTTPHandler(metricsHandler))
}
// Define main router
apiRouter := app.Group("/api")
////////////////////////
// UNPROTECTED ROUTES //
////////////////////////
unprotectedAPIRouter := apiRouter.Group("/")
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security, config: cfg}.GetConfig)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
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
app.Get("/", SinglePageApplication(cfg.UI))
app.Get("/endpoints/:key", SinglePageApplication(cfg.UI))
app.Get("/suites/:key", SinglePageApplication(cfg.UI))
// Health endpoint
healthHandler := health.Handler().WithJSON(true)
app.Get("/health", func(c *fiber.Ctx) error {
statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
return c.Status(statusCode).Send(body)
})
// Custom CSS
app.Get("/css/custom.css", CustomCSSHandler{customCSS: cfg.UI.CustomCSS}.GetCustomCSS)
// Everything else falls back on static content
app.Use(redirect.New(redirect.Config{
Rules: map[string]string{
"/index.html": "/",
},
StatusCode: 301,
}))
staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
if err != nil {
panic(err)
}
app.Use("/", fiberfs.New(fiberfs.Config{
Root: http.FS(staticFileSystem),
Index: "index.html",
Browse: true,
}))
//////////////////////
// PROTECTED ROUTES //
//////////////////////
// ORDER IS IMPORTANT: all routes applied AFTER the security middleware will require authn
protectedAPIRouter := apiRouter.Group("/")
if cfg.Security != nil {
if err := cfg.Security.RegisterHandlers(app); err != nil {
panic(err)
}
if err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {
panic(err)
}
}
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
protectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
protectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
return app
}
================================================
FILE: api/api_test.go
================================================
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2"
)
func TestNew(t *testing.T) {
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
WithSecurity bool
}
scenarios := []Scenario{
{
Name: "health",
Path: "/health",
ExpectedCode: fiber.StatusOK,
},
{
Name: "custom.css",
Path: "/css/custom.css",
ExpectedCode: fiber.StatusOK,
},
{
Name: "custom.css-gzipped",
Path: "/css/custom.css",
ExpectedCode: fiber.StatusOK,
Gzip: true,
},
{
Name: "metrics",
Path: "/metrics",
ExpectedCode: fiber.StatusOK,
},
{
Name: "favicon.ico",
Path: "/favicon.ico",
ExpectedCode: fiber.StatusOK,
},
{
Name: "app.js",
Path: "/js/app.js",
ExpectedCode: fiber.StatusOK,
},
{
Name: "app.js-gzipped",
Path: "/js/app.js",
ExpectedCode: fiber.StatusOK,
Gzip: true,
},
{
Name: "chunk-vendors.js",
Path: "/js/chunk-vendors.js",
ExpectedCode: fiber.StatusOK,
},
{
Name: "chunk-vendors.js-gzipped",
Path: "/js/chunk-vendors.js",
ExpectedCode: fiber.StatusOK,
Gzip: true,
},
{
Name: "index",
Path: "/",
ExpectedCode: fiber.StatusOK,
},
{
Name: "index-html-redirect",
Path: "/index.html",
ExpectedCode: fiber.StatusMovedPermanently,
},
{
Name: "index-should-return-200-even-if-not-authenticated",
Path: "/",
ExpectedCode: fiber.StatusOK,
WithSecurity: true,
},
{
Name: "endpoints-should-return-401-if-not-authenticated",
Path: "/api/v1/endpoints/statuses",
ExpectedCode: fiber.StatusUnauthorized,
WithSecurity: true,
},
{
Name: "config-should-return-200-even-if-not-authenticated",
Path: "/api/v1/config",
ExpectedCode: fiber.StatusOK,
WithSecurity: true,
},
{
Name: "config-should-always-return-200",
Path: "/api/v1/config",
ExpectedCode: fiber.StatusOK,
WithSecurity: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &config.Config{Metrics: true, UI: &ui.Config{}}
if scenario.WithSecurity {
cfg.Security = &security.Config{
Basic: &security.BasicConfig{
Username: "john.doe",
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
},
}
}
api := New(cfg)
router := api.Router()
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
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)
}
})
}
}
================================================
FILE: api/badge.go
================================================
package api
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/gofiber/fiber/v2"
)
const (
badgeColorHexAwesome = "#40cc11"
badgeColorHexGreat = "#94cc11"
badgeColorHexGood = "#ccd311"
badgeColorHexPassable = "#ccb311"
badgeColorHexBad = "#cc8111"
badgeColorHexVeryBad = "#c7130a"
)
const (
HealthStatusUp = "up"
HealthStatusDown = "down"
HealthStatusUnknown = "?"
)
var (
badgeColors = []string{badgeColorHexAwesome, badgeColorHexGreat, badgeColorHexGood, badgeColorHexPassable, badgeColorHexBad}
)
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for :duration -> 30d, 7d, 24h, 1h
func UptimeBadge(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
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) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send(generateUptimeBadgeSVG(duration, uptime))
}
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for :duration -> 30d, 7d, 24h, 1h
func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, cfg))
}
}
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
func HealthBadge(c *fiber.Ctx) error {
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
healthStatus := HealthStatusUnknown
if len(status.Results) > 0 {
if status.Results[0].Success {
healthStatus = HealthStatusUp
} else {
healthStatus = HealthStatusDown
}
}
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send(generateHealthBadgeSVG(healthStatus))
}
func HealthBadgeShields(c *fiber.Ctx) error {
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
healthStatus := HealthStatusUnknown
if len(status.Results) > 0 {
if status.Results[0].Success {
healthStatus = HealthStatusUp
} else {
healthStatus = HealthStatusDown
}
}
c.Set("Content-Type", "application/json")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
jsonData, err := generateHealthBadgeShields(healthStatus)
if err != nil {
return c.Status(500).SendString(err.Error())
}
return c.Status(200).Send(jsonData)
}
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int
switch duration {
case "30d":
labelWidth = 70
case "7d":
labelWidth = 65
case "24h":
labelWidth = 70
case "1h":
labelWidth = 65
default:
}
color := getBadgeColorFromUptime(uptime)
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", uptime*100), "0"), ".") + "%"
if strings.Contains(sanitizedValue, ".") {
valueWidthAdjustment = -10
}
valueWidth = (len(sanitizedValue) * 11) + valueWidthAdjustment
width := labelWidth + valueWidth
labelX := labelWidth / 2
valueX := labelWidth + (valueWidth / 2)
svg := []byte(fmt.Sprintf(`
uptime %s
uptime %s
%s
%s
`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
return svg
}
func getBadgeColorFromUptime(uptime float64) string {
if uptime >= 0.975 {
return badgeColorHexAwesome
} else if uptime >= 0.95 {
return badgeColorHexGreat
} else if uptime >= 0.9 {
return badgeColorHexGood
} else if uptime >= 0.8 {
return badgeColorHexPassable
} else if uptime >= 0.65 {
return badgeColorHexBad
}
return badgeColorHexVeryBad
}
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
var labelWidth, valueWidth int
switch duration {
case "30d":
labelWidth = 110
case "7d":
labelWidth = 105
case "24h":
labelWidth = 110
case "1h":
labelWidth = 105
default:
}
color := getBadgeColorFromResponseTime(averageResponseTime, key, cfg)
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
valueWidth = len(sanitizedValue) * 11
width := labelWidth + valueWidth
labelX := labelWidth / 2
valueX := labelWidth + (valueWidth / 2)
svg := []byte(fmt.Sprintf(`
response time %s
response time %s
%s
%s
`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
return svg
}
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
thresholds := ui.GetDefaultConfig().Badge.ResponseTime.Thresholds
if endpoint := cfg.GetEndpointByKey(key); endpoint != nil {
thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds
}
// the threshold config requires 5 values, so we can be sure it's set here
for i := range 5 {
if responseTime <= thresholds[i] {
return badgeColors[i]
}
}
return badgeColorHexVeryBad
}
func generateHealthBadgeSVG(healthStatus string) []byte {
var labelWidth, valueWidth int
switch healthStatus {
case HealthStatusUp:
valueWidth = 28
case HealthStatusDown:
valueWidth = 44
case HealthStatusUnknown:
valueWidth = 10
default:
}
color := getBadgeColorFromHealth(healthStatus)
labelWidth = 48
width := labelWidth + valueWidth
labelX := labelWidth / 2
valueX := labelWidth + (valueWidth / 2)
svg := []byte(fmt.Sprintf(`
health
health
%s
%s
`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, labelX, valueX, healthStatus, valueX, healthStatus))
return svg
}
func generateHealthBadgeShields(healthStatus string) ([]byte, error) {
color := getBadgeShieldsColorFromHealth(healthStatus)
data := map[string]interface{}{
"schemaVersion": 1,
"label": "gatus",
"message": healthStatus,
"color": color,
}
return json.Marshal(data)
}
func getBadgeColorFromHealth(healthStatus string) string {
if healthStatus == HealthStatusUp {
return badgeColorHexAwesome
} else if healthStatus == HealthStatusDown {
return badgeColorHexVeryBad
}
return badgeColorHexPassable
}
func getBadgeShieldsColorFromHealth(healthStatus string) string {
if healthStatus == HealthStatusUp {
return "brightgreen"
} else if healthStatus == HealthStatusDown {
return "red"
}
return "yellow"
}
================================================
FILE: api/badge_test.go
================================================
package api
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestBadge(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",
},
},
}
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "badge-uptime-1h",
Path: "/api/v1/endpoints/core_frontend/uptimes/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-24h",
Path: "/api/v1/endpoints/core_backend/uptimes/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-7d",
Path: "/api/v1/endpoints/core_frontend/uptimes/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-uptime-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/uptimes/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-uptime-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/uptimes/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "badge-response-time-1h",
Path: "/api/v1/endpoints/core_frontend/response-times/1h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/badge.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "badge-response-time-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "badge-health-up",
Path: "/api/v1/endpoints/core_frontend/health/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-health-down",
Path: "/api/v1/endpoints/core_backend/health/badge.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-health-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
ExpectedCode: http.StatusNotFound,
},
{
Name: "badge-shields-health-up",
Path: "/api/v1/endpoints/core_frontend/health/badge.shields",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-shields-health-down",
Path: "/api/v1/endpoints/core_backend/health/badge.shields",
ExpectedCode: http.StatusOK,
},
{
Name: "badge-shields-health-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/health/badge.shields",
ExpectedCode: http.StatusNotFound,
},
{
Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusBadRequest,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
response, err := router.Test(request)
if err != nil {
return
}
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)
}
})
}
}
func TestGetBadgeColorFromUptime(t *testing.T) {
scenarios := []struct {
Uptime float64
ExpectedColor string
}{
{
Uptime: 1,
ExpectedColor: badgeColorHexAwesome,
},
{
Uptime: 0.99,
ExpectedColor: badgeColorHexAwesome,
},
{
Uptime: 0.97,
ExpectedColor: badgeColorHexGreat,
},
{
Uptime: 0.95,
ExpectedColor: badgeColorHexGreat,
},
{
Uptime: 0.93,
ExpectedColor: badgeColorHexGood,
},
{
Uptime: 0.9,
ExpectedColor: badgeColorHexGood,
},
{
Uptime: 0.85,
ExpectedColor: badgeColorHexPassable,
},
{
Uptime: 0.7,
ExpectedColor: badgeColorHexBad,
},
{
Uptime: 0.65,
ExpectedColor: badgeColorHexBad,
},
{
Uptime: 0.6,
ExpectedColor: badgeColorHexVeryBad,
},
}
for _, scenario := range scenarios {
t.Run("uptime-"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) {
if getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor {
t.Errorf("expected %s from %f, got %v", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime))
}
})
}
}
func TestGetBadgeColorFromResponseTime(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
var (
firstCondition = endpoint.Condition("[STATUS] == 200")
secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
)
firstTestEndpoint := endpoint.Endpoint{
Name: "a",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
UIConfig: ui.GetDefaultConfig(),
}
secondTestEndpoint := endpoint.Endpoint{
Name: "b",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
UIConfig: &ui.Config{
Badge: &ui.Badge{
ResponseTime: &ui.ResponseTime{
Thresholds: []int{100, 500, 1000, 2000, 3000},
},
},
},
}
cfg := &config.Config{
Metrics: true,
Endpoints: []*endpoint.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
}
testSuccessfulResult := endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: time.Now(),
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
store.Get().InsertEndpointResult(&firstTestEndpoint, &testSuccessfulResult)
store.Get().InsertEndpointResult(&secondTestEndpoint, &testSuccessfulResult)
scenarios := []struct {
Key string
ResponseTime int
ExpectedColor string
}{
{
Key: firstTestEndpoint.Key(),
ResponseTime: 10,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 75,
ExpectedColor: badgeColorHexGreat,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 150,
ExpectedColor: badgeColorHexGreat,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 201,
ExpectedColor: badgeColorHexGood,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 300,
ExpectedColor: badgeColorHexGood,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 301,
ExpectedColor: badgeColorHexPassable,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 450,
ExpectedColor: badgeColorHexPassable,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 700,
ExpectedColor: badgeColorHexBad,
},
{
Key: firstTestEndpoint.Key(),
ResponseTime: 1500,
ExpectedColor: badgeColorHexVeryBad,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 50,
ExpectedColor: badgeColorHexAwesome,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 1500,
ExpectedColor: badgeColorHexPassable,
},
{
Key: secondTestEndpoint.Key(),
ResponseTime: 2222,
ExpectedColor: badgeColorHexBad,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Key+"-response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
if getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg) != scenario.ExpectedColor {
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg))
}
})
}
}
func TestGetBadgeColorFromHealth(t *testing.T) {
scenarios := []struct {
HealthStatus string
ExpectedColor string
}{
{
HealthStatus: HealthStatusUp,
ExpectedColor: badgeColorHexAwesome,
},
{
HealthStatus: HealthStatusDown,
ExpectedColor: badgeColorHexVeryBad,
},
{
HealthStatus: HealthStatusUnknown,
ExpectedColor: badgeColorHexPassable,
},
}
for _, scenario := range scenarios {
t.Run("health-"+scenario.HealthStatus, func(t *testing.T) {
if getBadgeColorFromHealth(scenario.HealthStatus) != scenario.ExpectedColor {
t.Errorf("expected %s from %s, got %v", scenario.ExpectedColor, scenario.HealthStatus, getBadgeColorFromHealth(scenario.HealthStatus))
}
})
}
}
================================================
FILE: api/cache.go
================================================
package api
import (
"time"
"github.com/TwiN/gocache/v2"
)
const (
cacheTTL = 10 * time.Second
)
var (
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
)
================================================
FILE: api/chart.go
================================================
package api
import (
"errors"
"math"
"net/http"
"net/url"
"sort"
"time"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
const timeFormat = "3:04PM"
var (
gridStyle = chart.Style{
StrokeColor: drawing.Color{R: 119, G: 119, B: 119, A: 40},
StrokeWidth: 1.0,
}
axisStyle = chart.Style{
FontColor: drawing.Color{R: 119, G: 119, B: 119, A: 255},
}
transparentStyle = chart.Style{
FillColor: drawing.Color{R: 255, G: 255, B: 255, A: 0},
}
)
func ResponseTimeChart(c *fiber.Ctx) error {
duration := c.Params("duration")
chartTimestampFormatter := chart.TimeValueFormatterWithFormat(timeFormat)
var from time.Time
switch duration {
case "30d":
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
chartTimestampFormatter = chart.TimeDateValueFormatter
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")
}
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else 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(204).SendString("")
}
series := chart.TimeSeries{
Name: "Average response time per hour",
Style: chart.Style{
StrokeWidth: 1.5,
DotWidth: 2.0,
},
}
keys := make([]int, 0, len(hourlyAverageResponseTime))
earliestTimestamp := int64(0)
for hourlyTimestamp := range hourlyAverageResponseTime {
keys = append(keys, int(hourlyTimestamp))
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
earliestTimestamp = hourlyTimestamp
}
}
for earliestTimestamp > from.Unix() {
earliestTimestamp -= int64(time.Hour.Seconds())
keys = append(keys, int(earliestTimestamp))
}
sort.Ints(keys)
var maxAverageResponseTime float64
for _, key := range keys {
averageResponseTime := float64(hourlyAverageResponseTime[int64(key)])
if maxAverageResponseTime < averageResponseTime {
maxAverageResponseTime = averageResponseTime
}
series.XValues = append(series.XValues, time.Unix(int64(key), 0))
series.YValues = append(series.YValues, averageResponseTime)
}
graph := chart.Chart{
Canvas: transparentStyle,
Background: transparentStyle,
Width: 1280,
Height: 300,
XAxis: chart.XAxis{
ValueFormatter: chartTimestampFormatter,
GridMajorStyle: gridStyle,
GridMinorStyle: gridStyle,
Style: axisStyle,
NameStyle: axisStyle,
},
YAxis: chart.YAxis{
Name: "Average response time",
GridMajorStyle: gridStyle,
GridMinorStyle: gridStyle,
Style: axisStyle,
NameStyle: axisStyle,
Range: &chart.ContinuousRange{
Min: 0,
Max: math.Ceil(maxAverageResponseTime * 1.25),
},
},
Series: []chart.Series{series},
}
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store")
c.Set("Expires", "0")
c.Status(http.StatusOK)
if err := graph.Render(chart.SVG, c); err != nil {
logr.Errorf("[api.ResponseTimeChart] Failed to render response time chart: %s", err.Error())
return c.Status(500).SendString(err.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,
})
}
================================================
FILE: api/chart_test.go
================================================
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestResponseTimeChart(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
Gzip bool
}
scenarios := []Scenario{
{
Name: "chart-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d/chart.svg",
ExpectedCode: http.StatusOK,
},
{
Name: "chart-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "chart-response-time-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
response, err := router.Test(request)
if err != nil {
return
}
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)
}
})
}
}
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)
}
})
}
}
================================================
FILE: api/config.go
================================================
package api
import (
"encoding/json"
"fmt"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2"
)
type ConfigHandler struct {
securityConfig *security.Config
config *config.Config
}
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
hasOIDC := false
isAuthenticated := true // Default to true if no security config is set
if handler.securityConfig != nil {
hasOIDC = handler.securityConfig.OIDC != nil
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
}
// Prepare response with announcements
response := map[string]interface{}{
"oidc": hasOIDC,
"authenticated": isAuthenticated,
}
// Add announcements if available, otherwise use empty slice
if handler.config != nil && handler.config.Announcements != nil && len(handler.config.Announcements) > 0 {
response["announcements"] = handler.config.Announcements
} else {
response["announcements"] = []interface{}{}
}
// Return the config as JSON
c.Set("Content-Type", "application/json")
responseBytes, err := json.Marshal(response)
if err != nil {
return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error()))
}
return c.Status(200).Send(responseBytes)
}
================================================
FILE: api/config_test.go
================================================
package api
import (
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2"
)
func TestConfigHandler_ServeHTTP(t *testing.T) {
securityConfig := &security.Config{
OIDC: &security.OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
},
}
handler := ConfigHandler{securityConfig: securityConfig}
// Create a fake router. We're doing this because I need the gate to be initialized.
app := fiber.New()
app.Get("/api/v1/config", handler.GetConfig)
err := securityConfig.ApplySecurityMiddleware(app)
if err != nil {
t.Error("expected err to be nil, but was", err)
}
// Test the config handler
request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Error("expected err to be nil, but was", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Error("expected code to be 200, but was", response.StatusCode)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Error("expected err to be nil, but was", err)
}
if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` {
t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body))
}
}
================================================
FILE: api/custom_css.go
================================================
package api
import (
"github.com/gofiber/fiber/v2"
)
type CustomCSSHandler struct {
customCSS string
}
func (handler CustomCSSHandler) GetCustomCSS(c *fiber.Ctx) error {
c.Set("Content-Type", "text/css")
return c.Status(200).SendString(handler.customCSS)
}
================================================
FILE: api/endpoint_status.go
================================================
package api
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
)
// EndpointStatuses handles requests to retrieve all EndpointStatus
// Due to how intensive this operation can be on the storage, this function leverages a cache.
func EndpointStatuses(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
var data []byte
if !exists {
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil {
logr.Errorf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
return c.Status(500).SendString(err.Error())
}
// ALPHA: Retrieve endpoint statuses from remote instances
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
logr.Errorf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
} else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
}
// Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses)
if err != nil {
logr.Errorf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
} else {
data = value.([]byte)
}
c.Set("Content-Type", "application/json")
return c.Status(200).Send(data)
}
}
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*endpoint.Status, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil
}
var endpointStatusesFromAllRemotes []*endpoint.Status
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL)
if err != nil {
// Log the error but continue with other instances
logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
var endpointStatuses []*endpoint.Status
if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {
_ = response.Body.Close()
logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to decode endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
_ = response.Body.Close()
for _, endpointStatus := range endpointStatuses {
endpointStatus.Name = instance.EndpointPrefix + endpointStatus.Name
}
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
}
// Only return nil, error if no remote instances were successfully processed
if len(endpointStatusesFromAllRemotes) == 0 && remoteConfig.Instances != nil {
return nil, fmt.Errorf("failed to retrieve endpoint statuses from all remote instances")
}
return endpointStatusesFromAllRemotes, nil
}
// EndpointStatus retrieves a single endpoint.Status by group and endpoint name
func EndpointStatus(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
logr.Errorf("[api.EndpointStatus] Failed to decode key: %s", err.Error())
return c.Status(400).SendString("invalid key encoding")
}
endpointStatus, err := store.Get().GetEndpointStatusByKey(key, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
}
logr.Errorf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
return c.Status(500).SendString(err.Error())
}
if endpointStatus == nil { // XXX: is this check necessary?
logr.Errorf("[api.EndpointStatus] Endpoint with key=%s not found", key)
return c.Status(404).SendString("not found")
}
output, err := json.Marshal(endpointStatus)
if err != nil {
logr.Errorf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
c.Set("Content-Type", "application/json")
return c.Status(200).Send(output)
}
}
================================================
FILE: api/endpoint_status_test.go
================================================
package api
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
var (
timestamp = time.Now()
testEndpoint = endpoint.Endpoint{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500"), endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: timestamp,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
func TestEndpointStatus(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",
},
},
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
}
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
Gzip bool
}
scenarios := []Scenario{
{
Name: "endpoint-status",
Path: "/api/v1/endpoints/core_frontend/statuses",
ExpectedCode: http.StatusOK,
},
{
Name: "endpoint-status-gzip",
Path: "/api/v1/endpoints/core_frontend/statuses",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "endpoint-status-pagination",
Path: "/api/v1/endpoints/core_frontend/statuses?page=1&pageSize=20",
ExpectedCode: http.StatusOK,
},
{
Name: "endpoint-status-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/statuses",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
response, err := router.Test(request)
if err != nil {
return
}
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)
}
})
}
}
func TestEndpointStatuses(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
firstResult := &testSuccessfulResult
secondResult := &testUnsuccessfulResult
store.Get().InsertEndpointResult(&testEndpoint, firstResult)
store.Get().InsertEndpointResult(&testEndpoint, secondResult)
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
api := New(&config.Config{
Metrics: true,
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
})
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
ExpectedBody string
}
scenarios := []Scenario{
{
Name: "no-pagination",
Path: "/api/v1/endpoints/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
{
Name: "pagination-first-result",
Path: "/api/v1/endpoints/statuses?page=1&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
{
Name: "pagination-second-result",
Path: "/api/v1/endpoints/statuses?page=2&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
{
Name: "pagination-no-results",
Path: "/api/v1/endpoints/statuses?page=5&pageSize=20",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[]}]`,
},
{
Name: "invalid-pagination-should-fall-back-to-default",
Path: "/api/v1/endpoints/statuses?page=INVALID&pageSize=INVALID",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}]}]`,
},
}
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 {
return
}
defer response.Body.Close()
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)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Error("expected err to be nil, but was", err)
}
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, string(body))
}
})
}
}
================================================
FILE: api/external_endpoint.go
================================================
package api
import (
"errors"
"strings"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/watchdog"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
)
func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
extraLabels := cfg.GetUniqueExtraMetricLabels()
return func(c *fiber.Ctx) error {
// Check if the success query parameter is present
success, exists := c.Queries()["success"]
if !exists || (success != "true" && success != "false") {
return c.Status(400).SendString("missing or invalid success query parameter")
}
// Check if the authorization bearer token header is correct
authorizationHeader := string(c.Request().Header.Peek("Authorization"))
if !strings.HasPrefix(authorizationHeader, "Bearer ") {
return c.Status(401).SendString("invalid Authorization header")
}
token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer "))
if len(token) == 0 {
return c.Status(401).SendString("bearer token must not be empty")
}
key := c.Params("key")
externalEndpoint := cfg.GetExternalEndpointByKey(key)
if externalEndpoint == nil {
logr.Errorf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key)
return c.Status(404).SendString("not found")
}
if externalEndpoint.Token != token {
logr.Errorf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key)
return c.Status(401).SendString("invalid token")
}
// Persist the result in the storage
result := &endpoint.Result{
Timestamp: time.Now(),
Success: c.QueryBool("success"),
Errors: []string{},
}
if len(c.Query("duration")) > 0 {
parsedDuration, err := time.ParseDuration(c.Query("duration"))
if err != nil {
logr.Errorf("[api.CreateExternalEndpointResult] Invalid duration from string=%s with error: %s", c.Query("duration"), err.Error())
return c.Status(400).SendString("invalid duration: " + err.Error())
}
result.Duration = parsedDuration
}
if errorFromQuery := c.Query("error"); !result.Success && len(errorFromQuery) > 0 {
result.AddError(errorFromQuery)
}
convertedEndpoint := externalEndpoint.ToEndpoint()
if err := store.Get().InsertEndpointResult(convertedEndpoint, result); err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
}
logr.Errorf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error())
return c.Status(500).SendString(err.Error())
}
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
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() && !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)
}
// Return the result
return c.Status(200).SendString("")
}
}
================================================
FILE: api/external_endpoint_test.go
================================================
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
)
func TestCreateExternalEndpointResult(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Alerting: &alerting.Config{
Discord: &discord.AlertProvider{},
},
ExternalEndpoints: []*endpoint.ExternalEndpoint{
{
Name: "n",
Group: "g",
Token: "token",
Alerts: []*alert.Alert{
{
Type: alert.TypeDiscord,
FailureThreshold: 2,
SuccessThreshold: 2,
},
},
},
},
Maintenance: &maintenance.Config{},
}
api := New(cfg)
router := api.Router()
scenarios := []struct {
Name string
Path string
AuthorizationHeaderBearerToken string
ExpectedCode int
}{
{
Name: "no-token",
Path: "/api/v1/endpoints/g_n/external?success=true",
AuthorizationHeaderBearerToken: "",
ExpectedCode: 401,
},
{
Name: "bad-token",
Path: "/api/v1/endpoints/g_n/external?success=true",
AuthorizationHeaderBearerToken: "Bearer bad-token",
ExpectedCode: 401,
},
{
Name: "bad-key",
Path: "/api/v1/endpoints/bad_key/external?success=true",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 404,
},
{
Name: "bad-success-value",
Path: "/api/v1/endpoints/g_n/external?success=invalid",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 400,
},
{
Name: "bad-duration-value",
Path: "/api/v1/endpoints/g_n/external?success=true&duration=invalid",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 400,
},
{
Name: "good-token-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-true-with-ignored-error-because-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-duration-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true&duration=10s",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-false",
Path: "/api/v1/endpoints/g_n/external?success=false",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-false-again",
Path: "/api/v1/endpoints/g_n/external?success=false",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-token-success-false-with-error",
Path: "/api/v1/endpoints/g_n/external?success=false&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("POST", scenario.Path, http.NoBody)
if len(scenario.AuthorizationHeaderBearerToken) > 0 {
request.Header.Set("Authorization", scenario.AuthorizationHeaderBearerToken)
}
response, err := router.Test(request)
if err != nil {
return
}
defer response.Body.Close()
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)
}
})
}
t.Run("verify-end-results", func(t *testing.T) {
endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 11))
if err != nil {
t.Errorf("failed to get endpoint status: %s", err.Error())
return
}
if endpointStatus.Key != "g_n" {
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
}
if len(endpointStatus.Results) != 6 {
t.Errorf("expected 6 results but got %d", len(endpointStatus.Results))
}
if !endpointStatus.Results[0].Success {
t.Errorf("expected first result to be successful")
}
if !endpointStatus.Results[1].Success {
t.Errorf("expected second result to be successful")
}
if len(endpointStatus.Results[1].Errors) > 0 {
t.Errorf("expected second result to have no errors")
}
if endpointStatus.Results[2].Duration == 0 || endpointStatus.Results[2].Duration.Seconds() != 10 {
t.Errorf("expected third result to have a duration of 10 seconds")
}
if endpointStatus.Results[3].Success {
t.Errorf("expected fourth result to be unsuccessful")
}
if endpointStatus.Results[4].Success {
t.Errorf("expected fifth result to be unsuccessful")
}
if endpointStatus.Results[5].Success {
t.Errorf("expected sixth result to be unsuccessful")
}
if len(endpointStatus.Results[5].Errors) == 0 || endpointStatus.Results[5].Errors[0] != "failed" {
t.Errorf("expected sixth result to have errors: failed")
}
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
if externalEndpointFromConfig.NumberOfFailuresInARow != 3 {
t.Errorf("expected 3 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow)
}
if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 {
t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow)
}
})
}
================================================
FILE: api/raw.go
================================================
package api
import (
"errors"
"fmt"
"net/url"
"time"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/gofiber/fiber/v2"
)
func UptimeRaw(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
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) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "text/plain")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send([]byte(fmt.Sprintf("%f", uptime)))
}
func ResponseTimeRaw(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
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) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "text/plain")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send([]byte(fmt.Sprintf("%d", responseTime)))
}
================================================
FILE: api/raw_test.go
================================================
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestRawDataEndpoint(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",
},
},
}
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "raw-uptime-1h",
Path: "/api/v1/endpoints/core_frontend/uptimes/1h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-24h",
Path: "/api/v1/endpoints/core_backend/uptimes/24h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-7d",
Path: "/api/v1/endpoints/core_frontend/uptimes/7d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-30d",
Path: "/api/v1/endpoints/core_frontend/uptimes/30d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/uptimes/3d",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "raw-uptime-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/uptimes/7d",
ExpectedCode: http.StatusNotFound,
},
{
Name: "raw-response-times-1h",
Path: "/api/v1/endpoints/core_frontend/response-times/1h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "raw-response-times-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
response, err := router.Test(request)
if err != nil {
return
}
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)
}
})
}
}
================================================
FILE: api/spa.go
================================================
package api
import (
_ "embed"
"html/template"
"github.com/TwiN/gatus/v5/config/ui"
static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
)
func SinglePageApplication(uiConfig *ui.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
vd := ui.ViewData{UI: uiConfig}
{
themeFromCookie := string(c.Request().Header.Cookie("theme"))
if len(themeFromCookie) > 0 {
if themeFromCookie == "dark" {
vd.Theme = "dark"
}
} else if uiConfig.IsDarkMode() { // Since there's no theme cookie, we'll rely on ui.DarkMode
vd.Theme = "dark"
}
}
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
logr.Errorf("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error: %s", err.Error())
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
}
c.Set("Content-Type", "text/html")
err = t.Execute(c, vd)
if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
logr.Errorf("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s", err.Error())
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
}
return c.SendStatus(200)
}
}
================================================
FILE: api/spa_test.go
================================================
package api
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestSinglePageApplication(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",
},
},
UI: &ui.Config{
Title: "example-title",
},
}
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
Gzip bool
CookieDarkMode bool
UIDarkMode bool
ExpectedCode int
ExpectedDarkTheme bool
}
scenarios := []Scenario{
{
Name: "frontend-home",
Path: "/",
CookieDarkMode: true,
UIDarkMode: false,
ExpectedDarkTheme: true,
ExpectedCode: 200,
},
{
Name: "frontend-endpoint-light",
Path: "/endpoints/core_frontend",
CookieDarkMode: false,
UIDarkMode: false,
ExpectedDarkTheme: false,
ExpectedCode: 200,
},
{
Name: "frontend-endpoint-dark",
Path: "/endpoints/core_frontend",
CookieDarkMode: false,
UIDarkMode: true,
ExpectedDarkTheme: true,
ExpectedCode: 200,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg.UI.DarkMode = &scenario.UIDarkMode
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
if scenario.CookieDarkMode {
request.Header.Set("Cookie", "theme=dark")
}
response, err := router.Test(request)
if err != nil {
return
}
defer response.Body.Close()
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)
}
body, _ := io.ReadAll(response.Body)
strBody := string(body)
if !strings.Contains(strBody, cfg.UI.Title) {
t.Errorf("%s %s should have contained the title", request.Method, request.URL)
}
if scenario.ExpectedDarkTheme && !strings.Contains(strBody, "class=\"dark\"") {
t.Errorf("%s %s should have responded with dark mode headers", request.Method, request.URL)
}
if !scenario.ExpectedDarkTheme && strings.Contains(strBody, "class=\"dark\"") {
t.Errorf("%s %s should not have responded with dark mode headers", request.Method, request.URL)
}
})
}
}
================================================
FILE: api/suite_status.go
================================================
package api
import (
"fmt"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/gofiber/fiber/v2"
)
// SuiteStatuses handles requests to retrieve all suite statuses
func SuiteStatuses(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c, 100)
params := paging.NewSuiteStatusParams().WithPagination(page, pageSize)
suiteStatuses, err := store.Get().GetAllSuiteStatuses(params)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": fmt.Sprintf("Failed to retrieve suite statuses: %v", err),
})
}
// If no statuses exist yet, create empty ones from config
if len(suiteStatuses) == 0 {
for _, s := range cfg.Suites {
if s.IsEnabled() {
suiteStatuses = append(suiteStatuses, suite.NewStatus(s))
}
}
}
return c.Status(fiber.StatusOK).JSON(suiteStatuses)
}
}
// SuiteStatus handles requests to retrieve a single suite's status
func SuiteStatus(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c, 100)
key := c.Params("key")
params := paging.NewSuiteStatusParams().WithPagination(page, pageSize)
status, err := store.Get().GetSuiteStatusByKey(key, params)
if err != nil || status == nil {
// Try to find the suite in config
for _, s := range cfg.Suites {
if s.Key() == key {
status = suite.NewStatus(s)
break
}
}
if status == nil {
return c.Status(404).JSON(fiber.Map{
"error": fmt.Sprintf("Suite with key '%s' not found", key),
})
}
}
return c.Status(fiber.StatusOK).JSON(status)
}
}
================================================
FILE: api/suite_status_test.go
================================================
package api
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
var (
suiteTimestamp = time.Now()
testSuiteEndpoint1 = endpoint.Endpoint{
Name: "endpoint1",
Group: "suite-group",
URL: "https://example.org/endpoint1",
Method: "GET",
Interval: 30 * time.Second,
Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500")},
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuiteEndpoint2 = endpoint.Endpoint{
Name: "endpoint2",
Group: "suite-group",
URL: "https://example.org/endpoint2",
Method: "GET",
Interval: 30 * time.Second,
Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 300")},
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuite = suite.Suite{
Name: "test-suite",
Group: "suite-group",
Interval: 60 * time.Second,
Endpoints: []*endpoint.Endpoint{
&testSuiteEndpoint1,
&testSuiteEndpoint2,
},
}
testSuccessfulSuiteResult = suite.Result{
Name: "test-suite",
Group: "suite-group",
Success: true,
Timestamp: suiteTimestamp,
Duration: 250 * time.Millisecond,
EndpointResults: []*endpoint.Result{
{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Success: true,
Timestamp: suiteTimestamp,
Duration: 100 * time.Millisecond,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
},
},
{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Success: true,
Timestamp: suiteTimestamp,
Duration: 150 * time.Millisecond,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 300",
Success: true,
},
},
},
},
}
testUnsuccessfulSuiteResult = suite.Result{
Name: "test-suite",
Group: "suite-group",
Success: false,
Timestamp: suiteTimestamp,
Duration: 850 * time.Millisecond,
Errors: []string{"suite-error-1", "suite-error-2"},
EndpointResults: []*endpoint.Result{
{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Success: true,
Timestamp: suiteTimestamp,
Duration: 100 * time.Millisecond,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
},
},
{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 500,
Errors: []string{"endpoint-error-1"},
Success: false,
Timestamp: suiteTimestamp,
Duration: 750 * time.Millisecond,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: false,
},
{
Condition: "[RESPONSE_TIME] < 300",
Success: false,
},
},
},
},
}
)
func TestSuiteStatus(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Suites: []*suite.Suite{
{
Name: "frontend-suite",
Group: "core",
},
{
Name: "backend-suite",
Group: "core",
},
},
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
}
watchdog.UpdateSuiteStatus(cfg.Suites[0], &suite.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now(), Name: cfg.Suites[0].Name, Group: cfg.Suites[0].Group})
watchdog.UpdateSuiteStatus(cfg.Suites[1], &suite.Result{Success: false, Duration: time.Second, Timestamp: time.Now(), Name: cfg.Suites[1].Name, Group: cfg.Suites[1].Group})
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "suite-status",
Path: "/api/v1/suites/core_frontend-suite/statuses",
ExpectedCode: http.StatusOK,
},
{
Name: "suite-status-gzip",
Path: "/api/v1/suites/core_frontend-suite/statuses",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "suite-status-pagination",
Path: "/api/v1/suites/core_frontend-suite/statuses?page=1&pageSize=20",
ExpectedCode: http.StatusOK,
},
{
Name: "suite-status-for-invalid-key",
Path: "/api/v1/suites/invalid_key/statuses",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
if scenario.Gzip {
request.Header.Set("Accept-Encoding", "gzip")
}
response, err := router.Test(request)
if err != nil {
return
}
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)
}
})
}
}
func TestSuiteStatus_SuiteNotInStoreButInConfig(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
tests := []struct {
name string
suiteKey string
cfg *config.Config
expectedCode int
expectJSON bool
expectError string
}{
{
name: "suite-not-in-store-but-exists-in-config-enabled",
suiteKey: "test-group_test-suite",
cfg: &config.Config{
Metrics: true,
Suites: []*suite.Suite{
{
Name: "test-suite",
Group: "test-group",
Enabled: boolPtr(true),
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint-1",
Group: "test-group",
URL: "https://example.com",
},
},
},
},
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
},
expectedCode: http.StatusOK,
expectJSON: true,
},
{
name: "suite-not-in-store-but-exists-in-config-disabled",
suiteKey: "test-group_disabled-suite",
cfg: &config.Config{
Metrics: true,
Suites: []*suite.Suite{
{
Name: "disabled-suite",
Group: "test-group",
Enabled: boolPtr(false),
},
},
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
},
expectedCode: http.StatusOK,
expectJSON: true,
},
{
name: "suite-not-in-store-and-not-in-config",
suiteKey: "nonexistent_suite",
cfg: &config.Config{
Metrics: true,
Suites: []*suite.Suite{
{
Name: "different-suite",
Group: "different-group",
},
},
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
},
expectedCode: http.StatusNotFound,
expectError: "Suite with key 'nonexistent_suite' not found",
},
{
name: "suite-with-empty-group-in-config",
suiteKey: "_empty-group-suite",
cfg: &config.Config{
Metrics: true,
Suites: []*suite.Suite{
{
Name: "empty-group-suite",
Group: "",
},
},
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
},
expectedCode: http.StatusOK,
expectJSON: true,
},
{
name: "suite-nil-enabled-defaults-to-true",
suiteKey: "default_enabled-suite",
cfg: &config.Config{
Metrics: true,
Suites: []*suite.Suite{
{
Name: "enabled-suite",
Group: "default",
Enabled: nil,
},
},
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
},
expectedCode: http.StatusOK,
expectJSON: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
api := New(tt.cfg)
router := api.Router()
request := httptest.NewRequest("GET", "/api/v1/suites/"+tt.suiteKey+"/statuses", http.NoBody)
response, err := router.Test(request)
if err != nil {
t.Fatalf("Router test failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != tt.expectedCode {
t.Errorf("Expected status code %d, got %d", tt.expectedCode, response.StatusCode)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
bodyStr := string(body)
if tt.expectJSON {
if response.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected JSON content type, got %s", response.Header.Get("Content-Type"))
}
if len(bodyStr) == 0 || bodyStr[0] != '{' {
t.Errorf("Expected JSON response, got: %s", bodyStr)
}
}
if tt.expectError != "" {
if !contains(bodyStr, tt.expectError) {
t.Errorf("Expected error message '%s' in response, got: %s", tt.expectError, bodyStr)
}
}
})
}
}
func TestSuiteStatuses(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
firstResult := &testSuccessfulSuiteResult
secondResult := &testUnsuccessfulSuiteResult
store.Get().InsertSuiteResult(&testSuite, firstResult)
store.Get().InsertSuiteResult(&testSuite, secondResult)
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
for i := range firstResult.EndpointResults {
firstResult.EndpointResults[i].Timestamp = time.Time{}
}
for i := range secondResult.EndpointResults {
secondResult.EndpointResults[i].Timestamp = time.Time{}
}
api := New(&config.Config{
Metrics: true,
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
})
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
ExpectedBody string
}
scenarios := []Scenario{
{
Name: "no-pagination",
Path: "/api/v1/suites/statuses",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":true,"timestamp":"0001-01-01T00:00:00Z","duration":250000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 300","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]},{"name":"test-suite","group":"suite-group","success":false,"timestamp":"0001-01-01T00:00:00Z","duration":850000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":500,"hostname":"example.org","duration":750000000,"errors":["endpoint-error-1"],"conditionResults":[{"condition":"[STATUS] == 200","success":false},{"condition":"[RESPONSE_TIME] \u003c 300","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"errors":["suite-error-1","suite-error-2"]}]}]`,
},
{
Name: "pagination-first-result",
Path: "/api/v1/suites/statuses?page=1&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":false,"timestamp":"0001-01-01T00:00:00Z","duration":850000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":500,"hostname":"example.org","duration":750000000,"errors":["endpoint-error-1"],"conditionResults":[{"condition":"[STATUS] == 200","success":false},{"condition":"[RESPONSE_TIME] \u003c 300","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"errors":["suite-error-1","suite-error-2"]}]}]`,
},
{
Name: "pagination-second-result",
Path: "/api/v1/suites/statuses?page=2&pageSize=1",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":true,"timestamp":"0001-01-01T00:00:00Z","duration":250000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 300","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}]}]`,
},
{
Name: "pagination-no-results",
Path: "/api/v1/suites/statuses?page=5&pageSize=20",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[]}]`,
},
{
Name: "invalid-pagination-should-fall-back-to-default",
Path: "/api/v1/suites/statuses?page=INVALID&pageSize=INVALID",
ExpectedCode: http.StatusOK,
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":true,"timestamp":"0001-01-01T00:00:00Z","duration":250000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 300","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]},{"name":"test-suite","group":"suite-group","success":false,"timestamp":"0001-01-01T00:00:00Z","duration":850000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":500,"hostname":"example.org","duration":750000000,"errors":["endpoint-error-1"],"conditionResults":[{"condition":"[STATUS] == 200","success":false},{"condition":"[RESPONSE_TIME] \u003c 300","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"errors":["suite-error-1","suite-error-2"]}]}]`,
},
}
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 {
return
}
defer response.Body.Close()
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)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Error("expected err to be nil, but was", err)
}
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, string(body))
}
})
}
}
func TestSuiteStatuses_NoSuitesInStoreButExistInConfig(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Suites: []*suite.Suite{
{
Name: "config-only-suite-1",
Group: "test-group",
Enabled: boolPtr(true),
},
{
Name: "config-only-suite-2",
Group: "test-group",
Enabled: boolPtr(true),
},
{
Name: "disabled-suite",
Group: "test-group",
Enabled: boolPtr(false),
},
},
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
}
api := New(cfg)
router := api.Router()
request := httptest.NewRequest("GET", "/api/v1/suites/statuses", http.NoBody)
response, err := router.Test(request)
if err != nil {
t.Fatalf("Router test failed: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, response.StatusCode)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
bodyStr := string(body)
if !contains(bodyStr, "config-only-suite-1") {
t.Error("Expected config-only-suite-1 in response")
}
if !contains(bodyStr, "config-only-suite-2") {
t.Error("Expected config-only-suite-2 in response")
}
if contains(bodyStr, "disabled-suite") {
t.Error("Should not include disabled-suite in response")
}
}
func boolPtr(b bool) *bool {
return &b
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
func() bool {
for i := 1; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}())))
}
================================================
FILE: api/util.go
================================================
package api
import (
"strconv"
"github.com/gofiber/fiber/v2"
)
const (
// DefaultPage is the default page to use if none is specified or an invalid value is provided
DefaultPage = 1
// DefaultPageSize is the default page size to use if none is specified or an invalid value is provided
DefaultPageSize = 50
)
func extractPageAndPageSizeFromRequest(c *fiber.Ctx, maximumNumberOfResults int) (page, pageSize int) {
var err error
if pageParameter := c.Query("page"); len(pageParameter) == 0 {
page = DefaultPage
} else {
page, err = strconv.Atoi(pageParameter)
if err != nil {
page = DefaultPage
}
if page < 1 {
page = DefaultPage
}
}
if pageSizeParameter := c.Query("pageSize"); len(pageSizeParameter) == 0 {
pageSize = DefaultPageSize
} else {
pageSize, err = strconv.Atoi(pageSizeParameter)
if err != nil {
pageSize = DefaultPageSize
}
}
if page == 1 && pageSize > maximumNumberOfResults {
// If the page is 1 and the page size is greater than the maximum number of results, return
// no more than the maximum number of results
pageSize = maximumNumberOfResults
} else if pageSize < 1 {
pageSize = DefaultPageSize
}
return
}
================================================
FILE: api/util_test.go
================================================
package api
import (
"fmt"
"testing"
"github.com/TwiN/gatus/v5/storage"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
)
func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
type Scenario struct {
Name string
Page string
PageSize string
ExpectedPage int
ExpectedPageSize int
MaximumNumberOfResults int
}
scenarios := []Scenario{
{
Page: "1",
PageSize: "20",
ExpectedPage: 1,
ExpectedPageSize: 20,
MaximumNumberOfResults: 20,
},
{
Page: "2",
PageSize: "10",
ExpectedPage: 2,
ExpectedPageSize: 10,
MaximumNumberOfResults: 40,
},
{
Page: "2",
PageSize: "10",
ExpectedPage: 2,
ExpectedPageSize: 10,
MaximumNumberOfResults: 200,
},
{
Page: "1",
PageSize: "999999",
ExpectedPage: 1,
ExpectedPageSize: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfResults: 100,
},
{
Page: "-1",
PageSize: "-1",
ExpectedPage: DefaultPage,
ExpectedPageSize: DefaultPageSize,
MaximumNumberOfResults: 20,
},
{
Page: "invalid",
PageSize: "invalid",
ExpectedPage: DefaultPage,
ExpectedPageSize: DefaultPageSize,
MaximumNumberOfResults: 100,
},
}
for _, scenario := range scenarios {
t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) {
//request := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), http.NoBody)
app := fiber.New()
c := app.AcquireCtx(&fasthttp.RequestCtx{})
defer app.ReleaseCtx(c)
c.Request().SetRequestURI(fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize))
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c, scenario.MaximumNumberOfResults)
if actualPage != scenario.ExpectedPage {
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
}
if actualPageSize != scenario.ExpectedPageSize {
t.Errorf("expected %d, got %d", scenario.ExpectedPageSize, actualPageSize)
}
})
}
}
================================================
FILE: client/client.go
================================================
package client
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/smtp"
"os"
"runtime"
"strings"
"time"
"github.com/TwiN/gocache/v2"
"github.com/TwiN/logr"
"github.com/TwiN/whois"
"github.com/gorilla/websocket"
"github.com/ishidawataru/sctp"
"github.com/miekg/dns"
ping "github.com/prometheus-community/pro-bing"
"github.com/registrobr/rdap"
"github.com/registrobr/rdap/protocol"
"golang.org/x/crypto/ssh"
)
const (
dnsPort = 53
)
var (
// injectedHTTPClient is used for testing purposes
injectedHTTPClient *http.Client
whoisClient = whois.NewClient().WithReferralCache(true)
whoisExpirationDateCache = gocache.NewCache().WithMaxSize(10000).WithDefaultTTL(24 * time.Hour)
rdapClient = rdap.NewClient(nil)
)
// GetHTTPClient returns the shared HTTP client, or the client from the configuration passed
func GetHTTPClient(config *Config) *http.Client {
if injectedHTTPClient != nil {
return injectedHTTPClient
}
if config == nil {
return defaultConfig.getHTTPClient()
}
return config.getHTTPClient()
}
// GetDomainExpiration retrieves the duration until the domain provided expires
func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err error) {
var retrievedCachedValue bool
if v, exists := whoisExpirationDateCache.Get(hostname); exists {
domainExpiration = time.Until(v.(time.Time))
retrievedCachedValue = true
// If the domain OR the TTL is not going to expire in less than 24 hours
// we don't have to refresh the cache. Otherwise, we'll refresh it.
cacheEntryTTL, _ := whoisExpirationDateCache.TTL(hostname)
if cacheEntryTTL > 24*time.Hour && domainExpiration > 24*time.Hour {
// No need to refresh, so we'll just return the cached values
return domainExpiration, nil
}
}
whoisResponse, err := rdapQuery(hostname)
if err != nil {
// fallback to WHOIS protocol
whoisResponse, err = whoisClient.QueryAndParse(hostname)
}
if err != nil {
if !retrievedCachedValue { // Add an error unless we already retrieved a cached value
return 0, fmt.Errorf("error querying and parsing hostname using whois client: %w", err)
}
} else {
domainExpiration = time.Until(whoisResponse.ExpirationDate)
if domainExpiration > 720*time.Hour {
whoisExpirationDateCache.SetWithTTL(hostname, whoisResponse.ExpirationDate, 240*time.Hour)
} else {
whoisExpirationDateCache.SetWithTTL(hostname, whoisResponse.ExpirationDate, 72*time.Hour)
}
}
return domainExpiration, nil
}
// parseLocalAddressPlaceholder returns a string with the local address replaced
func parseLocalAddressPlaceholder(item string, localAddr net.Addr) string {
item = strings.ReplaceAll(item, "[LOCAL_ADDRESS]", localAddr.String())
return item
}
// CanCreateNetworkConnection checks whether a connection can be established with a TCP or UDP endpoint
func CanCreateNetworkConnection(netType string, address string, body string, config *Config) (bool, []byte) {
const (
MaximumMessageSize = 1024 // in bytes
)
connection, err := net.DialTimeout(netType, address, config.Timeout)
if err != nil {
return false, nil
}
defer connection.Close()
if body != "" {
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
connection.SetDeadline(time.Now().Add(config.Timeout))
_, err = connection.Write([]byte(body))
if err != nil {
return false, nil
}
buf := make([]byte, MaximumMessageSize)
n, err := connection.Read(buf)
if err != nil {
return false, nil
}
return true, buf[:n]
}
return true, nil
}
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
func CanCreateSCTPConnection(address string, config *Config) bool {
ch := make(chan bool, 1)
go (func(res chan bool) {
addr, err := sctp.ResolveSCTPAddr("sctp", address)
if err != nil {
res <- false
return
}
conn, err := sctp.DialSCTP("sctp", nil, addr)
if err != nil {
res <- false
return
}
_ = conn.Close()
res <- true
})(ch)
select {
case result := <-ch:
return result
case <-time.After(config.Timeout):
return false
}
}
// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol
func CanPerformStartTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
hostAndPort := strings.Split(address, ":")
if len(hostAndPort) != 2 {
return false, nil, errors.New("invalid address for starttls, format must be host:port")
}
var connection net.Conn
var dnsResolver *DNSResolverConfig
if config.HasCustomDNSResolver() {
dnsResolver, err = config.parseDNSResolver()
if err != nil {
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error: %s", err.Error())
} else {
dialer := &net.Dialer{
Resolver: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+":"+dnsResolver.Port)
},
},
}
connection, err = dialer.DialContext(context.Background(), "tcp", address)
if err != nil {
return
}
}
} else {
connection, err = net.DialTimeout("tcp", address, config.Timeout)
if err != nil {
return
}
}
smtpClient, err := smtp.NewClient(connection, hostAndPort[0])
if err != nil {
return
}
err = smtpClient.StartTLS(&tls.Config{
InsecureSkipVerify: config.Insecure,
ServerName: hostAndPort[0],
})
if err != nil {
return
}
if state, ok := smtpClient.TLSConnectionState(); ok {
certificate = state.PeerCertificates[0]
} else {
return false, nil, errors.New("could not get TLS connection state")
}
return true, certificate, nil
}
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
func CanPerformTLS(address string, body string, config *Config) (connected bool, response []byte, certificate *x509.Certificate, err error) {
const (
MaximumMessageSize = 1024 // in bytes
)
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{
InsecureSkipVerify: config.Insecure,
})
if err != nil {
return
}
defer connection.Close()
verifiedChains := connection.ConnectionState().VerifiedChains
// If config.Insecure is set to true, verifiedChains will be an empty list []
// We should get the parsed certificates from PeerCertificates, it can't be empty on the client side
// Reference: https://pkg.go.dev/crypto/tls#PeerCertificates
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
peerCertificates := connection.ConnectionState().PeerCertificates
certificate = peerCertificates[0]
} else {
certificate = verifiedChains[0][0]
}
connected = true
if body != "" {
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
connection.SetDeadline(time.Now().Add(config.Timeout))
_, err = connection.Write([]byte(body))
if err != nil {
return
}
buf := make([]byte, MaximumMessageSize)
var n int
n, err = connection.Read(buf)
if err != nil {
return
}
response = buf[:n]
}
return
}
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
// using the SSH protocol.
func CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {
var port string
if strings.Contains(address, ":") {
addressAndPort := strings.Split(address, ":")
if len(addressAndPort) != 2 {
return false, nil, errors.New("invalid address for ssh, format must be host:port")
}
address = addressAndPort[0]
port = addressAndPort[1]
} else {
port = "22"
}
// Build auth methods: prefer parsed private key if provided, fall back to password.
var authMethods []ssh.AuthMethod
if len(privateKey) > 0 {
if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
authMethods = append(authMethods, ssh.PublicKeys(signer))
} else {
return false, nil, fmt.Errorf("invalid private key: %w", err)
}
}
if len(password) > 0 {
authMethods = append(authMethods, ssh.Password(password))
}
cli, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: username,
Auth: authMethods,
Timeout: config.Timeout,
})
if err != nil {
return false, nil, err
}
return true, cli, nil
}
func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
var port string
if strings.Contains(address, ":") {
addressAndPort := strings.Split(address, ":")
if len(addressAndPort) != 2 {
return false, 1, errors.New("invalid address for ssh, format must be ssh://host:port")
}
address = addressAndPort[0]
port = addressAndPort[1]
} else {
port = "22"
}
dialer := net.Dialer{}
connStr := net.JoinHostPort(address, port)
conn, err := dialer.Dial("tcp", connStr)
if err != nil {
return false, 1, err
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(time.Second))
buf := make([]byte, 256)
_, err = io.ReadAtLeast(conn, buf, 1)
if err != nil {
return false, 1, err
}
return true, 0, err
}
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, []byte, error) {
type Body struct {
Command string `json:"command"`
}
defer sshClient.Close()
var b Body
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, nil, err
}
sess, err := sshClient.NewSession()
if err != nil {
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, nil, err
}
defer sess.Close()
err = sess.Wait()
output := stdout.Bytes()
if err == nil {
return true, 0, output, nil
}
var exitErr *ssh.ExitError
if ok := errors.As(err, &exitErr); !ok {
return false, 0, nil, err
}
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
//
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
func Ping(address string, config *Config) (bool, time.Duration) {
pinger := ping.New(address)
pinger.Count = 1
pinger.Timeout = config.Timeout
pinger.SetPrivileged(ShouldRunPingerAsPrivileged())
pinger.SetNetwork(config.Network)
err := pinger.Run()
if err != nil {
return false, 0
}
if pinger.Statistics() != nil {
// If the packet loss is 100, it means that the packet didn't reach the host
if pinger.Statistics().PacketLoss == 100 {
return false, pinger.Timeout
}
return true, pinger.Statistics().MaxRtt
}
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 (
Origin = "http://localhost/"
)
var (
dialer = websocket.Dialer{
EnableCompression: true,
}
wsHeaders = make(http.Header)
)
wsHeaders.Set("Origin", Origin)
for name, value := range headers {
wsHeaders.Set(name, value)
}
ctx := context.Background()
if config != nil {
if config.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, config.Timeout)
defer cancel()
}
dialer.TLSClientConfig = &tls.Config{
InsecureSkipVerify: config.Insecure,
}
if config.HasTLSConfig() && config.TLS.isValid() == nil {
dialer.TLSClientConfig = configureTLS(dialer.TLSClientConfig, *config.TLS)
}
}
// Dial URL
ws, _, err := dialer.DialContext(ctx, address, wsHeaders)
if err != nil {
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
}
defer ws.Close()
body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
// Write message
if err := ws.WriteMessage(websocket.TextMessage, []byte(body)); err != nil {
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
}
// Read message
msgType, msg, err := ws.ReadMessage()
if err != nil {
return false, nil, fmt.Errorf("error reading websocket message: %w", err)
} else if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {
return false, nil, fmt.Errorf("unexpected websocket message type: %d, expected %d or %d", msgType, websocket.TextMessage, websocket.BinaryMessage)
}
return true, msg, nil
}
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
if !strings.Contains(url, ":") {
url = fmt.Sprintf("%s:%d", url, dnsPort)
}
queryTypeAsUint16 := dns.StringToType[queryType]
// Special handling: if this is a PTR query and queryName looks like a plain IP,
// convert it to the proper reverse lookup domain automatically.
if queryTypeAsUint16 == dns.TypePTR &&
!strings.HasSuffix(queryName, ".in-addr.arpa.") &&
!strings.HasSuffix(queryName, ".ip6.arpa.") {
if rev, convErr := reverseNameForIP(queryName); convErr == nil {
queryName = rev
} else {
return false, "", nil, convErr
}
}
c := new(dns.Client)
m := new(dns.Msg)
m.SetQuestion(queryName, queryTypeAsUint16)
r, _, err := c.Exchange(m, url)
if err != nil {
logr.Infof("[client.QueryDNS] Error exchanging DNS message: %v", err)
return false, "", nil, err
}
connected = true
dnsRcode = dns.RcodeToString[r.Rcode]
for _, rr := range r.Answer {
switch rr.Header().Rrtype {
case dns.TypeA:
if a, ok := rr.(*dns.A); ok {
body = []byte(a.A.String())
}
case dns.TypeAAAA:
if aaaa, ok := rr.(*dns.AAAA); ok {
body = []byte(aaaa.AAAA.String())
}
case dns.TypeCNAME:
if cname, ok := rr.(*dns.CNAME); ok {
body = []byte(cname.Target)
}
case dns.TypeMX:
if mx, ok := rr.(*dns.MX); ok {
body = []byte(mx.Mx)
}
case dns.TypeNS:
if ns, ok := rr.(*dns.NS); ok {
body = []byte(ns.Ns)
}
case dns.TypePTR:
if ptr, ok := rr.(*dns.PTR); ok {
body = []byte(ptr.Ptr)
}
case dns.TypeSRV:
if srv, ok := rr.(*dns.SRV); ok {
body = []byte(fmt.Sprintf("%s:%d", srv.Target, srv.Port))
}
default:
body = []byte("query type is not supported yet")
}
}
return connected, dnsRcode, body, nil
}
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
func InjectHTTPClient(httpClient *http.Client) {
injectedHTTPClient = httpClient
}
// rdapQuery returns domain expiration via RDAP protocol
func rdapQuery(hostname string) (*whois.Response, error) {
data, _, err := rdapClient.Query(hostname, nil, nil)
if err != nil {
return nil, err
}
domain, ok := data.(*protocol.Domain)
if !ok {
return nil, errors.New("invalid domain type")
}
response := whois.Response{}
for _, e := range domain.Events {
if e.Action == "expiration" {
response.ExpirationDate = e.Date.Time
break
}
}
return &response, nil
}
// helper to reverse IP and add in-addr.arpa. IPv4 and IPv6
func reverseNameForIP(ipStr string) (string, error) {
ip := net.ParseIP(ipStr)
if ip == nil {
return "", fmt.Errorf("invalid IP: %s", ipStr)
}
if ipv4 := ip.To4(); ipv4 != nil {
parts := strings.Split(ipv4.String(), ".")
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
parts[i], parts[j] = parts[j], parts[i]
}
return strings.Join(parts, ".") + ".in-addr.arpa.", nil
}
ip = ip.To16()
hexStr := hex.EncodeToString(ip)
nibbles := strings.Split(hexStr, "")
for i, j := 0, len(nibbles)-1; i < j; i, j = i+1, j-1 {
nibbles[i], nibbles[j] = nibbles[j], nibbles[i]
}
return strings.Join(nibbles, ".") + ".ip6.arpa.", nil
}
================================================
FILE: client/client_test.go
================================================
package client
import (
"bytes"
"crypto/tls"
"io"
"net/http"
"net/netip"
"os"
"runtime"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/pattern"
"github.com/TwiN/gatus/v5/test"
)
func TestGetHTTPClient(t *testing.T) {
t.Parallel()
cfg := &Config{
Insecure: false,
IgnoreRedirect: false,
Timeout: 0,
DNSResolver: "tcp://1.1.1.1:53",
OAuth2Config: &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
},
}
err := cfg.ValidateAndSetDefaults()
if err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
}
if GetHTTPClient(cfg) == nil {
t.Error("expected client to not be nil")
}
if GetHTTPClient(nil) == nil {
t.Error("expected client to not be nil")
}
}
func TestRdapQuery(t *testing.T) {
t.Parallel()
if _, err := rdapQuery("1.1.1.1"); err == nil {
t.Error("expected an error due to the invalid domain type")
}
if _, err := rdapQuery("eurid.eu"); err == nil {
t.Error("expected an error as there is no RDAP support currently in .eu")
}
if response, err := rdapQuery("example.com"); err != nil {
t.Fatal("expected no error, got", err.Error())
} else if response.ExpirationDate.Unix() <= 0 {
t.Error("expected to have a valid expiry date, got", response.ExpirationDate.Unix())
}
}
func TestGetDomainExpiration(t *testing.T) {
t.Parallel()
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Fatalf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(time.Hour), 25*time.Hour)
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// Make sure the refresh works when the ttl is <24 hours
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(35*time.Hour), 23*time.Hour)
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
t.Errorf("expected error to be nil, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
}
func TestPing(t *testing.T) {
t.Parallel()
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond}); !success {
t.Error("expected true")
if rtt == 0 {
t.Error("Round-trip time returned on success should've higher than 0")
}
}
if success, rtt := Ping("256.256.256.256", &Config{Timeout: 500 * time.Millisecond}); success {
t.Error("expected false, because the IP is invalid")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("192.168.152.153", &Config{Timeout: 500 * time.Millisecond}); success {
t.Error("expected false, because the IP is valid but the host should be unreachable")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
// Can't perform integration tests (e.g. pinging public targets by single-stacked hostname) here,
// because ICMP is blocked in the network of GitHub-hosted runners.
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
t.Error("expected true")
if rtt == 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
t.Error("expected true")
if rtt == 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip4"}); success {
t.Error("expected false, because the IP isn't an IPv4 address")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond, Network: "ip6"}); success {
t.Error("expected false, because the IP isn't an IPv6 address")
if rtt != 0 {
t.Error("Round-trip time returned on failure should've been 0")
}
}
}
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
dnsresolver string
}
tests := []struct {
name string
args args
wantConnected bool
wantErr bool
}{
{
name: "invalid address",
args: args{
address: "test",
},
wantConnected: false,
wantErr: true,
},
{
name: "error dial",
args: args{
address: "test:1234",
},
wantConnected: false,
wantErr: true,
},
{
name: "valid starttls",
args: args{
address: "smtp.gmail.com:587",
},
wantConnected: true,
wantErr: false,
},
{
name: "dns resolver",
args: args{
address: "smtp.gmail.com:587",
dnsresolver: "tcp://1.1.1.1:53",
},
wantConnected: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, DNSResolver: tt.args.dnsresolver})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
}
if connected != tt.wantConnected {
t.Errorf("CanPerformStartTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
}
})
}
}
func TestCanPerformTLS(t *testing.T) {
type args struct {
address string
insecure bool
}
tests := []struct {
name string
args args
wantConnected bool
wantErr bool
}{
{
name: "invalid address",
args: args{
address: "test",
},
wantConnected: false,
wantErr: true,
},
{
name: "error dial",
args: args{
address: "test:1234",
},
wantConnected: false,
wantErr: true,
},
{
name: "valid tls",
args: args{
address: "smtp.gmail.com:465",
},
wantConnected: true,
wantErr: false,
},
{
name: "bad cert with insecure true",
args: args{
address: "expired.badssl.com:443",
insecure: true,
},
wantConnected: true,
wantErr: false,
},
{
name: "bad cert with insecure false",
args: args{
address: "expired.badssl.com:443",
insecure: false,
},
wantConnected: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
}
if connected != tt.wantConnected {
t.Errorf("CanPerformTLS() connected=%v, wantConnected=%v", connected, tt.wantConnected)
}
})
}
}
func TestCanCreateConnection(t *testing.T) {
t.Parallel()
connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second})
if connected {
t.Error("should've failed, because there's no port in the address")
}
connected, _ = CanCreateNetworkConnection("tcp", "1.1.1.1:53", "", &Config{Timeout: 5 * time.Second})
if !connected {
t.Error("should've succeeded, because that IP should always™ be up")
}
}
// This test checks if a HTTP client configured with `configureOAuth2()` automatically
// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization`
// header to all outgoing HTTP calls.
func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
t.Parallel()
defer InjectHTTPClient(nil)
oAuth2Config := &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
}
mockHttpClient := &http.Client{
Transport: test.MockRoundTripper(func(r *http.Request) *http.Response {
// if the mock HTTP client tries to get a token from the `token-server`
// we provide the expected token response
if r.Host == "token-server.local" {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(
[]byte(
`{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`,
),
)),
}
}
// to verify the headers were sent as expected, we echo them back in the
// `X-Org-Authorization` header and check if the token value matches our
// mocked `token-server` response
return &http.Response{
StatusCode: http.StatusOK,
Header: map[string][]string{
"X-Org-Authorization": {r.Header.Get("Authorization")},
},
Body: http.NoBody,
}
}),
}
mockHttpClientWithOAuth := configureOAuth2(mockHttpClient, *oAuth2Config)
InjectHTTPClient(mockHttpClientWithOAuth)
request, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:8282", http.NoBody)
if err != nil {
t.Error("expected no error, got", err.Error())
}
response, err := mockHttpClientWithOAuth.Do(request)
if err != nil {
t.Error("expected no error, got", err.Error())
}
if response.Header == nil {
t.Error("expected response headers, but got nil")
}
// the mock response echos the Authorization header used in the request back
// to us as `X-Org-Authorization` header, we check here if the value matches
// our expected token `secret-token`
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
t.Error("expected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
}
}
func TestQueryWebSocket(t *testing.T) {
t.Parallel()
_, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second})
if err == nil {
t.Error("expected an error due to the address being invalid")
}
_, _, err = QueryWebSocket("ws://example.org", "body", nil, &Config{Timeout: 2 * time.Second})
if err == nil {
t.Error("expected an error due to the target not being websocket-friendly")
}
}
func TestTlsRenegotiation(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
cfg TLSConfig
expectedConfig tls.RenegotiationSupport
}{
{
name: "default",
cfg: TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateNever,
},
{
name: "never",
cfg: TLSConfig{RenegotiationSupport: "never", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateNever,
},
{
name: "once",
cfg: TLSConfig{RenegotiationSupport: "once", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateOnceAsClient,
},
{
name: "freely",
cfg: TLSConfig{RenegotiationSupport: "freely", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateFreelyAsClient,
},
{
name: "not-valid-and-broken",
cfg: TLSConfig{RenegotiationSupport: "invalid", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedConfig: tls.RenegotiateNever,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
tls := &tls.Config{}
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)
}
})
}
}
func TestQueryDNS(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
inputDNS dns.Config
inputURL string
expectedDNSCode string
expectedBody string
isErrExpected bool
}{
{
name: "test Config with type A",
inputDNS: dns.Config{
QueryType: "A",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "__IPV4__",
},
{
name: "test Config with type AAAA",
inputDNS: dns.Config{
QueryType: "AAAA",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "__IPV6__",
},
{
name: "test Config with type CNAME",
inputDNS: dns.Config{
QueryType: "CNAME",
QueryName: "en.wikipedia.org.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "dyna.wikimedia.org.",
},
{
name: "test Config with type MX",
inputDNS: dns.Config{
QueryType: "MX",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: ".",
},
{
name: "test Config with type NS",
inputDNS: dns.Config{
QueryType: "NS",
QueryName: "example.com.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "*.ns.cloudflare.com.",
},
{
name: "test Config with type PTR",
inputDNS: dns.Config{
QueryType: "PTR",
QueryName: "8.8.8.8.in-addr.arpa.",
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "dns.google.",
},
{
name: "test Config with type PTR and forward IP / no in-addr",
inputDNS: dns.Config{
QueryType: "PTR",
QueryName: "1.0.0.1",
},
inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR",
expectedBody: "one.one.one.one.",
},
{
name: "test Config with fake type and retrieve error",
inputDNS: dns.Config{
QueryType: "B",
QueryName: "example",
},
inputURL: "8.8.8.8",
isErrExpected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
_, dnsRCode, body, err := QueryDNS(scenario.inputDNS.QueryType, scenario.inputDNS.QueryName, scenario.inputURL)
if scenario.isErrExpected && err == nil {
t.Errorf("there should be an error")
}
if dnsRCode != scenario.expectedDNSCode {
t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode)
}
if scenario.inputDNS.QueryType == "NS" {
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
if !pattern.Match(scenario.expectedBody, string(body)) {
t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody)
}
} else {
if string(body) != scenario.expectedBody {
// little hack to validate arbitrary ipv4/ipv6
switch scenario.expectedBody {
case "__IPV4__":
if addr, err := netip.ParseAddr(string(body)); err != nil {
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
} else if !addr.Is4() {
t.Errorf("got %s, expected valid IPv4", string(body))
}
case "__IPV6__":
if addr, err := netip.ParseAddr(string(body)); err != nil {
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
} else if !addr.Is6() {
t.Errorf("got %s, expected valid IPv6", string(body))
}
default:
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
}
}
}
})
time.Sleep(10 * time.Millisecond)
}
}
func TestCheckSSHBanner(t *testing.T) {
t.Parallel()
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)
}
if status != 0 {
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)
}
if status != 1 {
t.Errorf("Expected: 1, got: %v", status)
}
})
}
================================================
FILE: client/config.go
================================================
package client
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"time"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
"github.com/TwiN/logr"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
"google.golang.org/api/idtoken"
)
const (
defaultTimeout = 10 * time.Second
)
var (
ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
ErrInvalidClientOAuth2Config = errors.New("invalid oauth2 configuration: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)")
ErrInvalidClientIAPConfig = errors.New("invalid Identity-Aware-Proxy configuration: must define all fields for Google Identity-Aware-Proxy programmatic authentication (audience)")
ErrInvalidClientTLSConfig = errors.New("invalid TLS configuration: certificate-file and private-key-file must be specified")
defaultConfig = Config{
Insecure: false,
IgnoreRedirect: false,
Timeout: defaultTimeout,
Network: "ip",
}
)
// GetDefaultConfig returns a copy of the default configuration
func GetDefaultConfig() *Config {
cfg := defaultConfig
return &cfg
}
// Config is the configuration for clients
type Config struct {
// ProxyURL is the URL of the proxy to use for the client
ProxyURL string `yaml:"proxy-url,omitempty"`
// Insecure determines whether to skip verifying the server's certificate chain and host name
Insecure bool `yaml:"insecure,omitempty"`
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
IgnoreRedirect bool `yaml:"ignore-redirect,omitempty"`
// Timeout for the client
Timeout time.Duration `yaml:"timeout"`
// DNSResolver override for the HTTP client
// Expected format is {protocol}://{host}:{port}, e.g. tcp://8.8.8.8:53
DNSResolver string `yaml:"dns-resolver,omitempty"`
// OAuth2Config is the OAuth2 configuration used for the client.
//
// If non-nil, the http.Client returned by getHTTPClient will automatically retrieve a token if necessary.
// See configureOAuth2 for more details.
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`
// 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.
type DNSResolverConfig struct {
Protocol string
Host string
Port string
}
// OAuth2Config is the configuration for the OAuth2 client credentials flow
type OAuth2Config struct {
TokenURL string `yaml:"token-url"` // e.g. https://dev-12345678.okta.com/token
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
}
// IAPConfig is the configuration for the Google Cloud Identity-Aware-Proxy
type IAPConfig struct {
Audience string `yaml:"audience"` // e.g. "toto.apps.googleusercontent.com"
}
// TLSConfig is the configuration for mTLS configurations
type TLSConfig struct {
// CertificateFile is the public certificate for TLS in PEM format.
CertificateFile string `yaml:"certificate-file,omitempty"`
// PrivateKeyFile is the private key file for TLS in PEM format.
PrivateKeyFile string `yaml:"private-key-file,omitempty"`
RenegotiationSupport string `yaml:"renegotiation,omitempty"`
}
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
func (c *Config) ValidateAndSetDefaults() error {
if c.Timeout < time.Millisecond {
c.Timeout = 10 * time.Second
}
if c.HasCustomDNSResolver() {
// Validate the DNS resolver now to make sure it will not return an error later.
if _, err := c.parseDNSResolver(); err != nil {
return err
}
}
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
return ErrInvalidClientOAuth2Config
}
if c.HasIAPConfig() && !c.IAPConfig.isValid() {
return ErrInvalidClientIAPConfig
}
if c.HasTLSConfig() {
if err := c.TLS.isValid(); err != nil {
return err
}
}
return nil
}
// HasCustomDNSResolver returns whether a custom DNSResolver is configured
func (c *Config) HasCustomDNSResolver() bool {
return len(c.DNSResolver) > 0
}
// parseDNSResolver parses the DNS resolver into the DNSResolverConfig struct
func (c *Config) parseDNSResolver() (*DNSResolverConfig, error) {
re := regexp.MustCompile(`^(?P(.*))://(?P[A-Za-z0-9\-\.]+):(?P[0-9]+)?(.*)$`)
matches := re.FindStringSubmatch(c.DNSResolver)
if len(matches) == 0 {
return nil, ErrInvalidDNSResolver
}
r := make(map[string]string)
for i, k := range re.SubexpNames() {
if i != 0 && k != "" {
r[k] = matches[i]
}
}
port, err := strconv.Atoi(r["port"])
if err != nil {
return nil, err
}
if port < 1 || port > 65535 {
return nil, ErrInvalidDNSResolverPort
}
return &DNSResolverConfig{
Protocol: r["proto"],
Host: r["host"],
Port: r["port"],
}, nil
}
// HasOAuth2Config returns true if the client has OAuth2 configuration parameters
func (c *Config) HasOAuth2Config() bool {
return c.OAuth2Config != nil
}
// HasIAPConfig returns true if the client has IAP configuration parameters
func (c *Config) HasIAPConfig() bool {
return c.IAPConfig != nil
}
// HasTLSConfig returns true if the client has client certificate parameters
func (c *Config) HasTLSConfig() bool {
return c.TLS != nil && len(c.TLS.CertificateFile) > 0 && len(c.TLS.PrivateKeyFile) > 0
}
// isValid() returns true if the IAP configuration is valid
func (c *IAPConfig) isValid() bool {
return len(c.Audience) > 0
}
// isValid() returns true if the OAuth2 configuration is valid
func (c *OAuth2Config) isValid() bool {
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
}
// isValid() returns nil if the client tls certificates are valid, otherwise returns an error
func (t *TLSConfig) isValid() error {
if len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 {
_, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile)
if err != nil {
return err
}
return nil
}
return ErrInvalidClientTLSConfig
}
// getHTTPClient return an HTTP client matching the Config's parameters.
func (c *Config) getHTTPClient() *http.Client {
tlsConfig := &tls.Config{
InsecureSkipVerify: c.Insecure,
}
if c.HasTLSConfig() && c.TLS.isValid() == nil {
tlsConfig = configureTLS(tlsConfig, *c.TLS)
}
if c.httpClient == nil {
c.httpClient = &http.Client{
Timeout: c.Timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: tlsConfig,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if c.IgnoreRedirect {
// Don't follow redirects
return http.ErrUseLastResponse
}
// Follow redirects
return nil
},
}
if c.ProxyURL != "" {
proxyURL, err := url.Parse(c.ProxyURL)
if err != nil {
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring custom proxy due to error: %s", err.Error())
} else {
c.httpClient.Transport.(*http.Transport).Proxy = http.ProxyURL(proxyURL)
}
}
if c.HasCustomDNSResolver() {
dnsResolver, err := c.parseDNSResolver()
if err != nil {
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error: %s", err.Error())
} else {
dialer := &net.Dialer{
Resolver: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+":"+dnsResolver.Port)
},
},
}
c.httpClient.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, addr)
}
}
}
if c.HasOAuth2Config() && c.HasIAPConfig() {
logr.Errorf("[client.getHTTPClient] Error: Both Identity-Aware-Proxy and Oauth2 configuration are present.")
} else if c.HasOAuth2Config() {
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
} 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
}
// validateIAPToken returns a boolean that will define if the Google identity-aware-proxy token can be fetched
// and if is it valid.
func validateIAPToken(ctx context.Context, c IAPConfig) bool {
ts, err := idtoken.NewTokenSource(ctx, c.Audience)
if err != nil {
logr.Errorf("[client.ValidateIAPToken] Claiming Identity token failed: %s", err.Error())
return false
}
tok, err := ts.Token()
if err != nil {
logr.Errorf("[client.ValidateIAPToken] Get Identity-Aware-Proxy token failed: %s", err.Error())
return false
}
_, err = idtoken.Validate(ctx, tok.AccessToken, c.Audience)
if err != nil {
logr.Errorf("[client.ValidateIAPToken] Token Validation failed: %s", err.Error())
return false
}
return true
}
// configureIAP returns an HTTP client that will obtain and refresh Identity-Aware-Proxy tokens as necessary.
// The returned Client and its Transport should not be modified.
func configureIAP(httpClient *http.Client, c IAPConfig) *http.Client {
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
if validateIAPToken(ctx, c) {
ts, err := idtoken.NewTokenSource(ctx, c.Audience)
if err != nil {
logr.Errorf("[client.configureIAP] Claiming Token Source failed: %s", err.Error())
return httpClient
}
client := oauth2.NewClient(ctx, ts)
client.Timeout = httpClient.Timeout
return client
}
return httpClient
}
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
// The returned Client and its Transport should not be modified.
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
oauth2cfg := clientcredentials.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: c.Scopes,
TokenURL: c.TokenURL,
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
client := oauth2cfg.Client(ctx)
client.Timeout = httpClient.Timeout
return client
}
// configureTLS returns a TLS Config that will enable mTLS
func configureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config {
clientTLSCert, err := tls.LoadX509KeyPair(c.CertificateFile, c.PrivateKeyFile)
if err != nil {
logr.Errorf("[client.configureTLS] Failed to load certificate: %s", err.Error())
return nil
}
tlsConfig.Certificates = []tls.Certificate{clientTLSCert}
tlsConfig.Renegotiation = tls.RenegotiateNever
renegotiationSupport := map[string]tls.RenegotiationSupport{
"once": tls.RenegotiateOnceAsClient,
"freely": tls.RenegotiateFreelyAsClient,
"never": tls.RenegotiateNever,
}
if val, ok := renegotiationSupport[c.RenegotiationSupport]; ok {
tlsConfig.Renegotiation = val
}
return tlsConfig
}
================================================
FILE: client/config_test.go
================================================
package client
import (
"net/http"
"net/url"
"testing"
"time"
)
func TestConfig_getHTTPClient(t *testing.T) {
insecureConfig := &Config{Insecure: true}
insecureConfig.ValidateAndSetDefaults()
insecureClient := insecureConfig.getHTTPClient()
if !(insecureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
t.Error("expected Config.Insecure set to true to cause the HTTP client to skip certificate verification")
}
if insecureClient.Timeout != defaultTimeout {
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
}
request, _ := http.NewRequest("GET", "", nil)
if err := insecureClient.CheckRedirect(request, nil); err != nil {
t.Error("expected Config.IgnoreRedirect set to false to cause the HTTP client's CheckRedirect to return nil")
}
secureConfig := &Config{IgnoreRedirect: true, Timeout: 5 * time.Second}
secureConfig.ValidateAndSetDefaults()
secureClient := secureConfig.getHTTPClient()
if (secureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
t.Error("expected Config.Insecure set to false to cause the HTTP client to not skip certificate verification")
}
if secureClient.Timeout != 5*time.Second {
t.Error("expected Config.Timeout to cause the HTTP client to have a timeout of 5s")
}
request, _ = http.NewRequest("GET", "", nil)
if err := secureClient.CheckRedirect(request, nil); err != http.ErrUseLastResponse {
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
}
}
func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
type args struct {
dnsResolver string
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "with-valid-resolver",
args: args{
dnsResolver: "tcp://1.1.1.1:53",
},
wantErr: false,
},
{
name: "with-invalid-resolver-port",
args: args{
dnsResolver: "tcp://127.0.0.1:99999",
},
wantErr: true,
},
{
name: "with-invalid-resolver-format",
args: args{
dnsResolver: "foobar",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
DNSResolver: tt.args.dnsResolver,
}
err := cfg.ValidateAndSetDefaults()
if (err != nil) != tt.wantErr {
t.Errorf("ValidateAndSetDefaults() error=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
func TestConfig_getHTTPClient_withCustomProxyURL(t *testing.T) {
proxyURL := "http://proxy.example.com:8080"
cfg := &Config{
ProxyURL: proxyURL,
}
cfg.ValidateAndSetDefaults()
client := cfg.getHTTPClient()
transport := client.Transport.(*http.Transport)
if transport.Proxy == nil {
t.Errorf("expected Config.ProxyURL to set the HTTP client's proxy to %s", proxyURL)
}
req := &http.Request{
URL: &url.URL{
Scheme: "http",
Host: "www.example.com",
},
}
expectProxyURL, err := transport.Proxy(req)
if err != nil {
t.Errorf("can't proxy the request %s", proxyURL)
}
if proxyURL != expectProxyURL.String() {
t.Errorf("expected Config.ProxyURL to set the HTTP client's proxy to %s", proxyURL)
}
}
func TestConfig_TlsIsValid(t *testing.T) {
tests := []struct {
name string
cfg *Config
expectedErr bool
}{
{
name: "good-tls-config",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}},
expectedErr: false,
},
{
name: "missing-certificate-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "doesnotexist", PrivateKeyFile: "../testdata/cert.key"}},
expectedErr: true,
},
{
name: "bad-certificate-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/cert.key"}},
expectedErr: true,
},
{
name: "no-certificate-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../testdata/cert.key"}},
expectedErr: true,
},
{
name: "missing-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "doesnotexist"}},
expectedErr: true,
},
{
name: "no-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: ""}},
expectedErr: true,
},
{
name: "bad-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/badcert.key"}},
expectedErr: true,
},
{
name: "bad-certificate-and-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/badcert.key"}},
expectedErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.cfg.TLS.isValid()
if (err != nil) != test.expectedErr {
t.Errorf("expected the existence of an error to be %v, got %v", test.expectedErr, err)
return
}
if !test.expectedErr {
if test.cfg.TLS.isValid() != nil {
t.Error("cfg.TLS.isValid() returned an error even though no error was expected")
}
}
})
}
}
================================================
FILE: client/grpc.go
================================================
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)
}
================================================
FILE: config/announcement/announcement.go
================================================
package announcement
import (
"errors"
"sort"
"time"
)
const (
// TypeOutage represents a service outage
TypeOutage = "outage"
// TypeWarning represents a warning or potential issue
TypeWarning = "warning"
// TypeInformation represents general information
TypeInformation = "information"
// TypeOperational represents operational status or resolved issues
TypeOperational = "operational"
// TypeNone represents no specific type (default)
TypeNone = "none"
)
var (
// ErrInvalidAnnouncementType is returned when an invalid announcement type is specified
ErrInvalidAnnouncementType = errors.New("invalid announcement type")
// ErrEmptyMessage is returned when an announcement has an empty message
ErrEmptyMessage = errors.New("announcement message cannot be empty")
// ErrMissingTimestamp is returned when an announcement has an empty timestamp
ErrMissingTimestamp = errors.New("announcement timestamp must be set")
// validTypes contains all valid announcement types
validTypes = map[string]bool{
TypeOutage: true,
TypeWarning: true,
TypeInformation: true,
TypeOperational: true,
TypeNone: true,
}
)
// Announcement represents a system-wide announcement
type Announcement struct {
// Timestamp is the UTC timestamp when the announcement was made
Timestamp time.Time `yaml:"timestamp" json:"timestamp"`
// Type is the type of announcement (outage, warning, information, operational, none)
Type string `yaml:"type" json:"type"`
// 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
func (a *Announcement) ValidateAndSetDefaults() error {
// Validate message
if a.Message == "" {
return ErrEmptyMessage
}
// Set default type if empty
if a.Type == "" {
a.Type = TypeNone
}
// Validate type
if !validTypes[a.Type] {
return ErrInvalidAnnouncementType
}
// If timestamp is zero, return an error
if a.Timestamp.IsZero() {
return ErrMissingTimestamp
}
return nil
}
// SortByTimestamp sorts a slice of announcements by timestamp in descending order (newest first)
func SortByTimestamp(announcements []*Announcement) {
sort.Slice(announcements, func(i, j int) bool {
return announcements[i].Timestamp.After(announcements[j].Timestamp)
})
}
// ValidateAndSetDefaults validates a slice of announcements and sets defaults
func ValidateAndSetDefaults(announcements []*Announcement) error {
for _, announcement := range announcements {
if err := announcement.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
================================================
FILE: config/announcement/announcement_test.go
================================================
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)
}
}
================================================
FILE: config/config.go
================================================
package config
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"github.com/TwiN/deepmerge"
"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"
"github.com/TwiN/gatus/v5/config/key"
"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"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
)
const (
// DefaultConfigurationFilePath is the default path that will be used to search for the configuration file
// if a custom path isn't configured through the GATUS_CONFIG_PATH environment variable
DefaultConfigurationFilePath = "config/config.yaml"
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
// configuration file if DefaultConfigurationFilePath didn't work
DefaultFallbackConfigurationFilePath = "config/config.yml"
// DefaultConcurrency is the default number of endpoints/suites that can be monitored concurrently
DefaultConcurrency = 3
)
var (
// ErrNoEndpointOrSuiteInConfig is an error returned when a configuration file or directory has no endpoints configured
ErrNoEndpointOrSuiteInConfig = errors.New("configuration should contain at least one endpoint or suite")
// ErrConfigFileNotFound is an error returned when a configuration file could not be found
ErrConfigFileNotFound = errors.New("configuration file not found")
// ErrInvalidSecurityConfig is an error returned when the security configuration is invalid
ErrInvalidSecurityConfig = errors.New("invalid security configuration")
// errEarlyReturn is returned to break out of a loop from a callback early
errEarlyReturn = errors.New("early escape")
)
// Config is the main configuration structure
type Config struct {
// Debug Whether to enable debug logs
// Deprecated: Use the GATUS_LOG_LEVEL environment variable instead
Debug bool `yaml:"debug,omitempty"`
// Metrics Whether to expose metrics at /metrics
Metrics bool `yaml:"metrics,omitempty"`
// SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration
// if the configuration file is updated while the application is running
SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update,omitempty"`
// DisableMonitoringLock Whether to disable the monitoring lock
// The monitoring lock is what prevents multiple endpoints from being processed at the same time.
// Disabling this may lead to inaccurate response times
//
// Deprecated: Use Concurrency instead TODO: REMOVE THIS IN v6.0.0
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
// Concurrency is the maximum number of endpoints/suites that can be monitored concurrently
// Defaults to DefaultConcurrency. Set to 0 for unlimited concurrency.
Concurrency int `yaml:"concurrency,omitempty"`
// Security is the configuration for securing access to Gatus
Security *security.Config `yaml:"security,omitempty"`
// Alerting is the configuration for alerting providers
Alerting *alerting.Config `yaml:"alerting,omitempty"`
// Endpoints is the list of endpoints to monitor
Endpoints []*endpoint.Endpoint `yaml:"endpoints,omitempty"`
// ExternalEndpoints is the list of all external endpoints
ExternalEndpoints []*endpoint.ExternalEndpoint `yaml:"external-endpoints,omitempty"`
// Suites is the list of suites to monitor
Suites []*suite.Suite `yaml:"suites,omitempty"`
// Storage is the configuration for how the data is stored
Storage *storage.Config `yaml:"storage,omitempty"`
// Web is the web configuration for the application
Web *web.Config `yaml:"web,omitempty"`
// UI is the configuration for the UI
UI *ui.Config `yaml:"ui,omitempty"`
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
// Remote is the configuration for remote Gatus instances
// WARNING: This is in ALPHA and may change or be completely removed in the future
Remote *remote.Config `yaml:"remote,omitempty"`
// 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"`
configPath string // path to the file or directory from which config was loaded
lastFileModTime time.Time // last modification time
}
// GetUniqueExtraMetricLabels returns a slice of unique metric labels from all enabled endpoints
// in the configuration. It iterates through each endpoint, checks if it is enabled,
// and then collects unique labels from the endpoint's labels map.
func (config *Config) GetUniqueExtraMetricLabels() []string {
labels := make([]string, 0)
for _, ep := range config.Endpoints {
if !ep.IsEnabled() {
continue
}
for label := range ep.ExtraLabels {
if slices.Contains(labels, label) {
continue
}
labels = append(labels, label)
}
}
if len(labels) > 1 {
sort.Strings(labels)
}
return labels
}
func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i]
if ep.Key() == strings.ToLower(key) {
return ep
}
}
return nil
}
func (config *Config) GetExternalEndpointByKey(key string) *endpoint.ExternalEndpoint {
for i := 0; i < len(config.ExternalEndpoints); i++ {
ee := config.ExternalEndpoints[i]
if ee.Key() == strings.ToLower(key) {
return ee
}
}
return nil
}
// HasLoadedConfigurationBeenModified returns whether one of the file that the
// configuration has been loaded from has been modified since it was last read
func (config *Config) HasLoadedConfigurationBeenModified() bool {
lastMod := config.lastFileModTime.Unix()
fileInfo, err := os.Stat(config.configPath)
if err != nil {
return false
}
if fileInfo.IsDir() {
err = walkConfigDir(config.configPath, func(path string, d fs.DirEntry, err error) error {
if info, err := d.Info(); err == nil && lastMod < info.ModTime().Unix() {
return errEarlyReturn
}
return nil
})
return errors.Is(err, errEarlyReturn)
}
return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()
}
// UpdateLastFileModTime refreshes Config.lastFileModTime
func (config *Config) UpdateLastFileModTime() {
config.lastFileModTime = time.Now()
}
// LoadConfiguration loads the full configuration composed of the main configuration file
// and all composed configuration files
func LoadConfiguration(configPath string) (*Config, error) {
var configBytes []byte
var fileInfo os.FileInfo
var usedConfigPath string
// Figure out what config path we'll use (either configPath or the default config path)
for _, configurationPath := range []string{configPath, DefaultConfigurationFilePath, DefaultFallbackConfigurationFilePath} {
if len(configurationPath) == 0 {
continue
}
var err error
fileInfo, err = os.Stat(configurationPath)
if err != nil {
continue
}
usedConfigPath = configurationPath
break
}
if len(usedConfigPath) == 0 {
return nil, ErrConfigFileNotFound
}
var config *Config
if fileInfo.IsDir() {
err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("error walking path %s: %w", path, err)
}
if strings.Contains(path, "..") {
logr.Warnf("[config.LoadConfiguration] Ignoring configuration from %s", path)
return nil
}
logr.Infof("[config.LoadConfiguration] Reading configuration from %s", path)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading configuration from file %s: %w", path, err)
}
configBytes, err = deepmerge.YAML(configBytes, data)
return err
})
if err != nil {
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
}
} else {
logr.Infof("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath)
if data, err := os.ReadFile(usedConfigPath); err != nil {
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
} else {
configBytes = data
}
}
if len(configBytes) == 0 {
return nil, ErrConfigFileNotFound
}
config, err := parseAndValidateConfigBytes(configBytes)
if err != nil {
return nil, fmt.Errorf("error parsing config: %w", err)
}
config.configPath = usedConfigPath
config.UpdateLastFileModTime()
return config, nil
}
// walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files
func walkConfigDir(path string, fn fs.WalkDirFunc) error {
if len(path) == 0 {
// If the user didn't provide a directory, we'll just use the default config file, so we can return nil now.
return nil
}
return filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d == nil || d.IsDir() {
return nil
}
ext := filepath.Ext(path)
if ext != ".yml" && ext != ".yaml" {
return nil
}
return fn(path, d, err)
})
}
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
// Replace $$ with __GATUS_LITERAL_DOLLAR_SIGN__ to prevent os.ExpandEnv from treating "$$" as if it was an
// environment variable. This allows Gatus to support literal "$" in the configuration file.
yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "$$", "__GATUS_LITERAL_DOLLAR_SIGN__"))
// Expand environment variables
yamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))
// Replace __GATUS_LITERAL_DOLLAR_SIGN__ with "$" to restore the literal "$" in the configuration file
yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "__GATUS_LITERAL_DOLLAR_SIGN__", "$"))
// Parse configuration file
if err = yaml.Unmarshal(yamlBytes, &config); err != nil {
return
}
// Check if the configuration file at least has endpoints configured
if config == nil || (len(config.Endpoints) == 0 && len(config.Suites) == 0) {
err = ErrNoEndpointOrSuiteInConfig
} else {
// XXX: Remove this in v6.0.0
if config.Debug {
logr.Warn("WARNING: The 'debug' configuration has been deprecated and will be removed in v6.0.0")
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 {
return nil, err
}
if err := ValidateEndpointsConfig(config); err != nil {
return nil, err
}
if err := ValidateWebConfig(config); err != nil {
return nil, err
}
if err := ValidateUIConfig(config); err != nil {
return nil, err
}
if err := ValidateMaintenanceConfig(config); err != nil {
return nil, err
}
if err := ValidateStorageConfig(config); err != nil {
return nil, err
}
if err := ValidateRemoteConfig(config); err != nil {
return nil, err
}
if err := ValidateConnectivityConfig(config); err != nil {
return nil, err
}
if err := ValidateTunnelingConfig(config); err != nil {
return nil, err
}
if err := ValidateAnnouncementsConfig(config); err != nil {
return nil, err
}
if err := ValidateSuitesConfig(config); err != nil {
return nil, err
}
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 {
if config.Connectivity != nil {
return config.Connectivity.ValidateAndSetDefaults()
}
return nil
}
// ValidateTunnelingConfig validates the tunneling configuration and resolves tunnel references
// NOTE: This must be called after ValidateEndpointsConfig and ValidateSuitesConfig
// 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
}
// Sort announcements by timestamp (newest first) for API response
announcement.SortByTimestamp(config.Announcements)
}
return nil
}
func ValidateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func ValidateStorageConfig(config *Config) error {
if config.Storage == nil {
config.Storage = &storage.Config{
Type: storage.TypeMemory,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
}
} else {
if err := config.Storage.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func ValidateMaintenanceConfig(config *Config) error {
if config.Maintenance == nil {
config.Maintenance = maintenance.GetDefaultConfig()
} else {
if err := config.Maintenance.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func ValidateUIConfig(config *Config) error {
if config.UI == nil {
config.UI = ui.GetDefaultConfig()
} else {
if err := config.UI.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func ValidateWebConfig(config *Config) error {
if config.Web == nil {
config.Web = web.GetDefaultConfig()
} else {
return config.Web.ValidateAndSetDefaults()
}
return nil
}
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())
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
} else {
duplicateValidationMap[endpointKey] = true
}
if err := ep.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
}
}
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())
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
} else {
duplicateValidationMap[endpointKey] = true
}
if err := ee.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
}
}
logr.Infof("[config.ValidateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
return nil
}
func ValidateSuitesConfig(config *Config) error {
if config.Suites == nil || len(config.Suites) == 0 {
logr.Info("[config.ValidateSuitesConfig] No suites configured")
return nil
}
suiteNames := make(map[string]bool)
for _, suite := range config.Suites {
// Check for duplicate suite names
if suiteNames[suite.Name] {
return fmt.Errorf("duplicate suite name: %s", suite.Key())
}
suiteNames[suite.Name] = true
// Validate the suite configuration
if err := suite.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid suite '%s': %w", suite.Key(), err)
}
// Check that endpoints referenced in Store mappings use valid placeholders
for _, suiteEndpoint := range suite.Endpoints {
if suiteEndpoint.Store != nil {
for contextKey, placeholder := range suiteEndpoint.Store {
// Basic validation that the context key is a valid identifier
if len(contextKey) == 0 {
return fmt.Errorf("suite '%s' endpoint '%s' has empty context key in store mapping", suite.Key(), suiteEndpoint.Key())
}
if len(placeholder) == 0 {
return fmt.Errorf("suite '%s' endpoint '%s' has empty placeholder in store mapping for key '%s'", suite.Key(), suiteEndpoint.Key(), contextKey)
}
}
}
}
}
logr.Infof("[config.ValidateSuitesConfig] Validated %d suite(s)", len(config.Suites))
return nil
}
func ValidateUniqueKeys(config *Config) error {
keyMap := make(map[string]string) // key -> description for error messages
// Check all endpoints
for _, ep := range config.Endpoints {
epKey := ep.Key()
if existing, exists := keyMap[epKey]; exists {
return fmt.Errorf("duplicate key '%s': endpoint '%s' conflicts with %s", epKey, ep.Key(), existing)
}
keyMap[epKey] = fmt.Sprintf("endpoint '%s'", ep.Key())
}
// Check all external endpoints
for _, ee := range config.ExternalEndpoints {
eeKey := ee.Key()
if existing, exists := keyMap[eeKey]; exists {
return fmt.Errorf("duplicate key '%s': external endpoint '%s' conflicts with %s", eeKey, ee.Key(), existing)
}
keyMap[eeKey] = fmt.Sprintf("external endpoint '%s'", ee.Key())
}
// Check all suites
for _, suite := range config.Suites {
suiteKey := suite.Key()
if existing, exists := keyMap[suiteKey]; exists {
return fmt.Errorf("duplicate key '%s': suite '%s' conflicts with %s", suiteKey, suite.Key(), existing)
}
keyMap[suiteKey] = fmt.Sprintf("suite '%s'", suite.Key())
// Check endpoints within suites (they generate keys using suite group + endpoint name)
for _, ep := range suite.Endpoints {
epKey := key.ConvertGroupAndNameToKey(suite.Group, ep.Name)
if existing, exists := keyMap[epKey]; exists {
return fmt.Errorf("duplicate key '%s': endpoint '%s' in suite '%s' conflicts with %s", epKey, epKey, suite.Key(), existing)
}
keyMap[epKey] = fmt.Sprintf("endpoint '%s' in suite '%s'", epKey, suite.Key())
}
}
return nil
}
func ValidateSecurityConfig(config *Config) error {
if config.Security != nil {
if !config.Security.ValidateAndSetDefaults() {
logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated")
return ErrInvalidSecurityConfig
}
}
return nil
}
// 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) {
if alertingConfig == nil {
logr.Info("[config.ValidateAlertingConfig] Alerting is not configured")
return
}
alertTypes := []alert.Type{
alert.TypeAWSSES,
alert.TypeClickUp,
alert.TypeCustom,
alert.TypeDatadog,
alert.TypeDiscord,
alert.TypeEmail,
alert.TypeGitHub,
alert.TypeGitLab,
alert.TypeGitea,
alert.TypeGoogleChat,
alert.TypeGotify,
alert.TypeHomeAssistant,
alert.TypeIFTTT,
alert.TypeIlert,
alert.TypeIncidentIO,
alert.TypeLine,
alert.TypeMatrix,
alert.TypeMattermost,
alert.TypeMessagebird,
alert.TypeN8N,
alert.TypeNewRelic,
alert.TypeNtfy,
alert.TypeOpsgenie,
alert.TypePagerDuty,
alert.TypePlivo,
alert.TypePushover,
alert.TypeRocketChat,
alert.TypeSendGrid,
alert.TypeSignal,
alert.TypeSIGNL4,
alert.TypeSlack,
alert.TypeSplunk,
alert.TypeSquadcast,
alert.TypeTeams,
alert.TypeTeamsWorkflows,
alert.TypeTelegram,
alert.TypeTwilio,
alert.TypeVonage,
alert.TypeWebex,
alert.TypeZapier,
alert.TypeZulip,
}
var validProviders, invalidProviders []alert.Type
for _, alertType := range alertTypes {
alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)
if alertProvider != nil {
if err := alertProvider.Validate(); err == nil {
// Parse alerts with the provider's default alert
if alertProvider.GetDefaultAlert() != nil {
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())
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())
}
}
}
}
}
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())
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())
}
}
}
}
}
}
validProviders = append(validProviders, alertType)
} else {
logr.Warnf("[config.ValidateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider)
}
} else {
invalidProviders = append(invalidProviders, alertType)
}
}
logr.Infof("[config.ValidateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
}
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")
} else if config.Concurrency <= 0 && !config.DisableMonitoringLock {
config.Concurrency = DefaultConcurrency
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
} else {
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
}
}
================================================
FILE: config/config_test.go
================================================
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"testing"
"time"
"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/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
"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/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/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
"github.com/TwiN/gatus/v5/alerting/provider/signal"
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
"github.com/TwiN/gatus/v5/alerting/provider/webex"
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
"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"
)
func TestLoadConfiguration(t *testing.T) {
yes := true
dir := t.TempDir()
scenarios := []struct {
name string
configPath string // value to pass as the configPath parameter in LoadConfiguration
pathAndFiles map[string]string // files to create in dir
expectedConfig *Config
expectedError error
}{
{
name: "empty-config-file",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": "",
},
expectedError: ErrConfigFileNotFound,
},
{
name: "config-file-that-does-not-exist",
configPath: filepath.Join(dir, "config.yaml"),
expectedError: ErrConfigFileNotFound,
},
{
name: "config-file-with-endpoint-that-has-no-url",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": `
endpoints:
- name: website`,
},
expectedError: endpoint.ErrEndpointWithNoURL,
},
{
name: "config-file-with-endpoint-that-has-no-conditions",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": `
endpoints:
- name: website
url: https://twin.sh/health`,
},
expectedError: endpoint.ErrEndpointWithNoCondition,
},
{
name: "config-file",
configPath: filepath.Join(dir, "config.yaml"),
pathAndFiles: map[string]string{
"config.yaml": `
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
expectedConfig: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "website",
URL: "https://twin.sh/health",
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
},
{
name: "empty-dir",
configPath: dir,
pathAndFiles: map[string]string{},
expectedError: ErrConfigFileNotFound,
},
{
name: "dir-with-empty-config-file",
configPath: dir,
pathAndFiles: map[string]string{
"config.yaml": "",
},
expectedError: ErrNoEndpointOrSuiteInConfig,
},
{
name: "dir-with-two-config-files",
configPath: dir,
pathAndFiles: map[string]string{
"config.yaml": `endpoints:
- name: one
url: https://example.com
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 200"
- name: two
url: https://example.org
conditions:
- "len([BODY]) > 0"`,
"config.yml": `endpoints:
- name: three
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"`,
},
expectedConfig: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "one",
URL: "https://example.com",
Conditions: []endpoint.Condition{"[CONNECTED] == true", "[STATUS] == 200"},
},
{
Name: "two",
URL: "https://example.org",
Conditions: []endpoint.Condition{"len([BODY]) > 0"},
},
{
Name: "three",
URL: "https://twin.sh/health",
Conditions: []endpoint.Condition{"[STATUS] == 200", "[BODY].status == UP"},
},
},
},
},
{
name: "dir-with-2-config-files-deep-merge-with-map-slice-and-primitive",
configPath: dir,
pathAndFiles: map[string]string{
"a.yaml": `
metrics: true
alerting:
slack:
webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz
default-alert:
enabled: true
endpoints:
- name: example
url: https://example.org
interval: 5s
conditions:
- "[STATUS] == 200"`,
"b.yaml": `
alerting:
discord:
webhook-url: https://discord.com/api/webhooks/xxx/yyy
external-endpoints:
- name: ext-ep-test
token: "potato"
alerts:
- type: slack
endpoints:
- name: frontend
url: https://example.com
conditions:
- "[STATUS] == 200"`,
},
expectedConfig: &Config{
Metrics: true,
Alerting: &alerting.Config{
Discord: &discord.AlertProvider{DefaultConfig: discord.Config{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}},
Slack: &slack.AlertProvider{DefaultConfig: slack.Config{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"}, DefaultAlert: &alert.Alert{Enabled: &yes}},
},
ExternalEndpoints: []*endpoint.ExternalEndpoint{
{
Name: "ext-ep-test",
Token: "potato",
Alerts: []*alert.Alert{
{
Type: alert.TypeSlack,
FailureThreshold: 3,
SuccessThreshold: 2,
},
},
},
},
Endpoints: []*endpoint.Endpoint{
{
Name: "example",
URL: "https://example.org",
Interval: 5 * time.Second,
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
{
Name: "frontend",
URL: "https://example.com",
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
for path, content := range scenario.pathAndFiles {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil {
t.Fatalf("[%s] failed to write file: %v", scenario.name, err)
}
}
defer func(pathAndFiles map[string]string) {
for path := range pathAndFiles {
_ = os.Remove(filepath.Join(dir, path))
}
}(scenario.pathAndFiles)
config, err := LoadConfiguration(scenario.configPath)
if !errors.Is(err, scenario.expectedError) {
t.Errorf("[%s] expected error %v, got %v", scenario.name, scenario.expectedError, err)
return
} else if err != nil && errors.Is(err, scenario.expectedError) {
return
}
// parse the expected output so that expectations are closer to reality (under the right circumstances, even I can be poetic)
expectedConfigAsYAML, _ := yaml.Marshal(scenario.expectedConfig)
expectedConfigAfterBeingParsedAndValidated, err := parseAndValidateConfigBytes(expectedConfigAsYAML)
if err != nil {
t.Fatalf("[%s] failed to parse expected config: %v", scenario.name, err)
}
// Marshal em' before comparing em' so that we don't have to deal with formatting and ordering
actualConfigAsYAML, err := yaml.Marshal(config)
if err != nil {
t.Fatalf("[%s] failed to marshal actual config: %v", scenario.name, err)
}
expectedConfigAfterBeingParsedAndValidatedAsYAML, _ := yaml.Marshal(expectedConfigAfterBeingParsedAndValidated)
if string(actualConfigAsYAML) != string(expectedConfigAfterBeingParsedAndValidatedAsYAML) {
t.Errorf("[%s] expected config %s, got %s", scenario.name, string(expectedConfigAfterBeingParsedAndValidatedAsYAML), string(actualConfigAsYAML))
}
})
}
}
func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configFilePath := filepath.Join(dir, "config.yaml")
_ = os.WriteFile(configFilePath, []byte(`endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`), 0o644)
t.Run("config-file-as-config-path", func(t *testing.T) {
config, err := LoadConfiguration(configFilePath)
if err != nil {
t.Fatalf("failed to load configuration: %v", err)
}
if config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created")
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(`endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory")
}
})
t.Run("config-directory-as-config-path", func(t *testing.T) {
config, err := LoadConfiguration(dir)
if err != nil {
t.Fatalf("failed to load configuration: %v", err)
}
if config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created")
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory")
}
})
}
func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: sqlite
path: %s
maximum-number-of-results: 10
maximum-number-of-events: 5
maintenance:
enabled: true
start: 00:00
duration: 4h
every: [Monday, Thursday]
ui:
title: T
header: H
link: https://example.org
buttons:
- name: "Home"
link: "https://example.org"
- name: "Status page"
link: "https://status.example.org"
external-endpoints:
- name: ext-ep-test
group: core
token: "potato"
endpoints:
- name: website
url: https://twin.sh/health
interval: 15s
conditions:
- "[STATUS] == 200"
- name: github
url: https://api.github.com/healthz
client:
insecure: true
ignore-redirect: true
timeout: 5s
conditions:
- "[STATUS] != 400"
- "[STATUS] != 500"
- name: example
url: https://example.com/
interval: 30m
client:
insecure: true
conditions:
- "[STATUS] == 200"
`, file)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
t.Error("expected storage to be set to sqlite, got", config.Storage)
}
if config.Storage == nil || config.Storage.MaximumNumberOfResults != 10 || config.Storage.MaximumNumberOfEvents != 5 {
t.Error("expected MaximumNumberOfResults and MaximumNumberOfEvents to be set to 10 and 5, got", config.Storage.MaximumNumberOfResults, config.Storage.MaximumNumberOfEvents)
}
if config.UI == nil || config.UI.Title != "T" || config.UI.Header != "H" || config.UI.Link != "https://example.org" || len(config.UI.Buttons) != 2 || config.UI.Buttons[0].Name != "Home" || config.UI.Buttons[0].Link != "https://example.org" || config.UI.Buttons[1].Name != "Status page" || config.UI.Buttons[1].Link != "https://status.example.org" {
t.Error("expected ui to be set to T, H, https://example.org, 2 buttons, Home and Status page, got", config.UI)
}
if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 {
t.Error("Expected Config.Maintenance to be configured properly")
}
if len(config.ExternalEndpoints) != 1 {
t.Error("Should have returned one external endpoint")
}
if config.ExternalEndpoints[0].Name != "ext-ep-test" {
t.Errorf("Name should have been %s", "ext-ep-test")
}
if config.ExternalEndpoints[0].Group != "core" {
t.Errorf("Group should have been %s", "core")
}
if config.ExternalEndpoints[0].Token != "potato" {
t.Errorf("Token should have been %s", "potato")
}
if len(config.Endpoints) != 3 {
t.Error("Should have returned two endpoints")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Endpoints[0].Method != "GET" {
t.Errorf("Method should have been %s (default)", "GET")
}
if config.Endpoints[0].Interval != 15*time.Second {
t.Errorf("Interval should have been %s", 15*time.Second)
}
if config.Endpoints[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Endpoints[0].ClientConfig.Insecure)
}
if config.Endpoints[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
t.Errorf("ClientConfig.IgnoreRedirect should have been %v, got %v", true, config.Endpoints[0].ClientConfig.IgnoreRedirect)
}
if config.Endpoints[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Errorf("ClientConfig.Timeout should have been %v, got %v", client.GetDefaultConfig().Timeout, config.Endpoints[0].ClientConfig.Timeout)
}
if len(config.Endpoints[0].Conditions) != 1 {
t.Errorf("There should have been %d conditions", 1)
}
if config.Endpoints[1].URL != "https://api.github.com/healthz" {
t.Errorf("URL should have been %s", "https://api.github.com/healthz")
}
if config.Endpoints[1].Method != "GET" {
t.Errorf("Method should have been %s (default)", "GET")
}
if config.Endpoints[1].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if !config.Endpoints[1].ClientConfig.Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Endpoints[1].ClientConfig.Insecure)
}
if !config.Endpoints[1].ClientConfig.IgnoreRedirect {
t.Errorf("ClientConfig.IgnoreRedirect should have been %v, got %v", true, config.Endpoints[1].ClientConfig.IgnoreRedirect)
}
if config.Endpoints[1].ClientConfig.Timeout != 5*time.Second {
t.Errorf("ClientConfig.Timeout should have been %v, got %v", 5*time.Second, config.Endpoints[1].ClientConfig.Timeout)
}
if len(config.Endpoints[1].Conditions) != 2 {
t.Errorf("There should have been %d conditions", 2)
}
if config.Endpoints[2].URL != "https://example.com/" {
t.Errorf("URL should have been %s", "https://example.com/")
}
if config.Endpoints[2].Method != "GET" {
t.Errorf("Method should have been %s (default)", "GET")
}
if config.Endpoints[2].Interval != 30*time.Minute {
t.Errorf("Interval should have been %s, because it is the default value", 30*time.Minute)
}
if !config.Endpoints[2].ClientConfig.Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, config.Endpoints[2].ClientConfig.Insecure)
}
if config.Endpoints[2].ClientConfig.IgnoreRedirect {
t.Errorf("ClientConfig.IgnoreRedirect should have been %v by default, got %v", false, config.Endpoints[2].ClientConfig.IgnoreRedirect)
}
if config.Endpoints[2].ClientConfig.Timeout != 10*time.Second {
t.Errorf("ClientConfig.Timeout should have been %v by default, got %v", 10*time.Second, config.Endpoints[2].ClientConfig.Timeout)
}
if len(config.Endpoints[2].Conditions) != 1 {
t.Errorf("There should have been %d conditions", 1)
}
}
func TestParseAndValidateConfigBytesDefault(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Web.Address != web.DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", web.DefaultAddress)
}
if config.Web.Port != web.DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", web.DefaultPort)
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
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 config.Endpoints[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
t.Errorf("ClientConfig.Insecure should have been %v by default, got %v", true, config.Endpoints[0].ClientConfig.Insecure)
}
if config.Endpoints[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
t.Errorf("ClientConfig.IgnoreRedirect should have been %v by default, got %v", true, config.Endpoints[0].ClientConfig.IgnoreRedirect)
}
if config.Endpoints[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Errorf("ClientConfig.Timeout should have been %v by default, got %v", client.GetDefaultConfig().Timeout, config.Endpoints[0].ClientConfig.Timeout)
}
}
func TestParseAndValidateConfigBytesWithAddress(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
web:
address: 127.0.0.1
endpoints:
- name: website
url: https://twin.sh/actuator/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Endpoints[0].URL != "https://twin.sh/actuator/health" {
t.Errorf("URL should have been %s", "https://twin.sh/actuator/health")
}
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 config.Web.Address != "127.0.0.1" {
t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1")
}
if config.Web.Port != web.DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", web.DefaultPort)
}
}
func TestParseAndValidateConfigBytesWithPort(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
web:
port: 12345
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
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 config.Web.Address != web.DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", web.DefaultAddress)
}
if config.Web.Port != 12345 {
t.Errorf("Port should have been %d, because it is specified in config", 12345)
}
}
func TestParseAndValidateConfigBytesWithPortAndHost(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
web:
port: 12345
address: 127.0.0.1
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
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 config.Web.Address != "127.0.0.1" {
t.Errorf("Bind address should have been %s, because it is specified in config", "127.0.0.1")
}
if config.Web.Port != 12345 {
t.Errorf("Port should have been %d, because it is specified in config", 12345)
}
}
func TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
web:
port: 65536
address: 127.0.0.1
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
if err == nil {
t.Fatal("Should've returned an error because the configuration specifies an invalid port value")
}
}
func TestParseAndValidateConfigBytesWithMetricsAndCustomUserAgentHeader(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
metrics: true
endpoints:
- name: website
url: https://twin.sh/health
headers:
User-Agent: Test/2.0
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if !config.Metrics {
t.Error("Metrics should have been true")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
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 config.Web.Address != web.DefaultAddress {
t.Errorf("Bind address should have been %s, because it is the default value", web.DefaultAddress)
}
if config.Web.Port != web.DefaultPort {
t.Errorf("Port should have been %d, because it is the default value", web.DefaultPort)
}
if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != "Test/2.0" {
t.Errorf("User-Agent should've been %s, got %s", "Test/2.0", userAgent)
}
}
func TestParseAndValidateConfigBytesWithMetricsAndHostAndPort(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
metrics: true
web:
address: 192.168.0.1
port: 9090
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if !config.Metrics {
t.Error("Metrics should have been true")
}
if config.Web.Address != "192.168.0.1" {
t.Errorf("Bind address should have been %s, because it is the default value", "192.168.0.1")
}
if config.Web.Port != 9090 {
t.Errorf("Port should have been %d, because it is specified in config", 9090)
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
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 userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != endpoint.GatusUserAgent {
t.Errorf("User-Agent should've been %s because it's the default value, got %s", endpoint.GatusUserAgent, userAgent)
}
}
func TestParseAndValidateBadConfigBytes(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
badconfig:
- asdsa: w0w
usadasdrl: asdxzczxc
asdas:
- soup
`))
if err == nil {
t.Error("An error should've been returned")
}
if err != ErrNoEndpointOrSuiteInConfig {
t.Error("The error returned should have been of type ErrNoEndpointOrSuiteInConfig")
}
}
func TestParseAndValidateConfigBytesWithAlerting(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
slack:
webhook-url: "http://example.com"
discord:
webhook-url: "http://example.org"
pagerduty:
integration-key: "00000000000000000000000000000000"
pushover:
application-token: "000000000000000000000000000000"
user-key: "000000000000000000000000000000"
mattermost:
webhook-url: "http://example.com"
client:
insecure: true
messagebird:
access-key: "1"
originator: "31619191918"
recipients: "31619191919"
telegram:
token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
id: 0123456789
twilio:
sid: "1234"
token: "5678"
from: "+1-234-567-8901"
to: "+1-234-567-8901"
teams:
webhook-url: "http://example.com"
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: slack
- type: pagerduty
failure-threshold: 7
success-threshold: 5
description: "Healthcheck failed 7 times in a row"
- type: mattermost
- type: messagebird
enabled: false
- type: discord
failure-threshold: 10
- type: telegram
enabled: true
- type: twilio
failure-threshold: 12
success-threshold: 15
- type: teams
- type: pushover
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
// Alerting providers
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
t.Fatal("Slack alerting config should've been valid")
}
// Endpoints
if len(config.Endpoints) != 1 {
t.Error("There should've been 1 endpoint")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
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) != 9 {
t.Fatal("There should've been 9 alerts configured")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[0].Type)
}
if !config.Endpoints[0].Alerts[0].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[0].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[0].FailureThreshold)
}
if config.Endpoints[0].Alerts[0].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[0].SuccessThreshold)
}
if config.Endpoints[0].Alerts[1].Type != alert.TypePagerDuty {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePagerDuty, config.Endpoints[0].Alerts[1].Type)
}
if config.Endpoints[0].Alerts[1].GetDescription() != "Healthcheck failed 7 times in a row" {
t.Errorf("The description of the alert should've been %s, but it was %s", "Healthcheck failed 7 times in a row", config.Endpoints[0].Alerts[1].GetDescription())
}
if config.Endpoints[0].Alerts[1].FailureThreshold != 7 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 7, config.Endpoints[0].Alerts[1].FailureThreshold)
}
if config.Endpoints[0].Alerts[1].SuccessThreshold != 5 {
t.Errorf("The success threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[1].SuccessThreshold)
}
if config.Endpoints[0].Alerts[2].Type != alert.TypeMattermost {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMattermost, config.Endpoints[0].Alerts[2].Type)
}
if !config.Endpoints[0].Alerts[2].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[2].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[2].FailureThreshold)
}
if config.Endpoints[0].Alerts[2].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[2].SuccessThreshold)
}
if config.Endpoints[0].Alerts[3].Type != alert.TypeMessagebird {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMessagebird, config.Endpoints[0].Alerts[3].Type)
}
if config.Endpoints[0].Alerts[3].IsEnabled() {
t.Error("The alert should've been disabled")
}
if config.Endpoints[0].Alerts[4].Type != alert.TypeDiscord {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.Endpoints[0].Alerts[4].Type)
}
if !config.Endpoints[0].Alerts[4].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[4].FailureThreshold != 10 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold)
}
if config.Endpoints[0].Alerts[4].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[4].SuccessThreshold)
}
if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTelegram, config.Endpoints[0].Alerts[5].Type)
}
if !config.Endpoints[0].Alerts[5].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[5].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[5].FailureThreshold)
}
if config.Endpoints[0].Alerts[5].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[5].SuccessThreshold)
}
if config.Endpoints[0].Alerts[6].Type != alert.TypeTwilio {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTwilio, config.Endpoints[0].Alerts[6].Type)
}
if !config.Endpoints[0].Alerts[6].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[6].FailureThreshold != 12 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 12, config.Endpoints[0].Alerts[6].FailureThreshold)
}
if config.Endpoints[0].Alerts[6].SuccessThreshold != 15 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Endpoints[0].Alerts[6].SuccessThreshold)
}
if config.Endpoints[0].Alerts[7].Type != alert.TypeTeams {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Endpoints[0].Alerts[7].Type)
}
if !config.Endpoints[0].Alerts[7].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[7].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[7].FailureThreshold)
}
if config.Endpoints[0].Alerts[7].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[7].SuccessThreshold)
}
if config.Endpoints[0].Alerts[8].Type != alert.TypePushover {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePushover, config.Endpoints[0].Alerts[8].Type)
}
if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled")
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
slack:
webhook-url: "http://example.com"
default-alert:
enabled: true
discord:
webhook-url: "http://example.org"
default-alert:
enabled: true
failure-threshold: 10
success-threshold: 15
pagerduty:
integration-key: "00000000000000000000000000000000"
default-alert:
enabled: true
description: default description
failure-threshold: 7
success-threshold: 5
pushover:
application-token: "000000000000000000000000000000"
user-key: "000000000000000000000000000000"
default-alert:
enabled: true
description: default description
failure-threshold: 5
success-threshold: 3
mattermost:
webhook-url: "http://example.com"
default-alert:
enabled: true
messagebird:
access-key: "1"
originator: "31619191918"
recipients: "31619191919"
default-alert:
enabled: false
send-on-resolved: true
telegram:
token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
id: 0123456789
default-alert:
enabled: true
twilio:
sid: "1234"
token: "5678"
from: "+1-234-567-8901"
to: "+1-234-567-8901"
default-alert:
enabled: true
failure-threshold: 12
success-threshold: 15
teams:
webhook-url: "http://example.com"
default-alert:
enabled: true
email:
from: "from@example.com"
username: "from@example.com"
password: "hunter2"
host: "mail.example.com"
port: 587
to: "recipient1@example.com,recipient2@example.com"
client:
insecure: false
default-alert:
enabled: true
gotify:
server-url: "https://gotify.example"
token: "**************"
default-alert:
enabled: true
external-endpoints:
- name: ext-ep-test
group: core
token: potato
alerts:
- type: discord
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: slack
- type: pagerduty
- type: mattermost
- type: messagebird
- type: discord
success-threshold: 8 # test endpoint alert override
- type: telegram
- type: twilio
- type: teams
- type: pushover
- type: email
- type: gotify
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Metrics {
t.Error("Metrics should've been false by default")
}
// Alerting providers
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
t.Fatal("Slack alerting config should've been valid")
}
if config.Alerting.Slack.GetDefaultAlert() == nil {
t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Slack.DefaultConfig.WebhookURL != "http://example.com" {
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.DefaultConfig.WebhookURL)
}
if config.Alerting.PagerDuty == nil || config.Alerting.PagerDuty.Validate() != nil {
t.Fatal("PagerDuty alerting config should've been valid")
}
if config.Alerting.PagerDuty.GetDefaultAlert() == nil {
t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.PagerDuty.DefaultConfig.IntegrationKey != "00000000000000000000000000000000" {
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.DefaultConfig.IntegrationKey)
}
if config.Alerting.Pushover == nil || config.Alerting.Pushover.Validate() != nil {
t.Fatal("Pushover alerting config should've been valid")
}
if config.Alerting.Pushover.GetDefaultAlert() == nil {
t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Pushover.DefaultConfig.ApplicationToken != "000000000000000000000000000000" {
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.ApplicationToken)
}
if config.Alerting.Pushover.DefaultConfig.UserKey != "000000000000000000000000000000" {
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.UserKey)
}
if config.Alerting.Mattermost == nil || config.Alerting.Mattermost.Validate() != nil {
t.Fatal("Mattermost alerting config should've been valid")
}
if config.Alerting.Mattermost.GetDefaultAlert() == nil {
t.Fatal("Mattermost.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Messagebird == nil || config.Alerting.Messagebird.Validate() != nil {
t.Fatal("Messagebird alerting config should've been valid")
}
if config.Alerting.Messagebird.GetDefaultAlert() == nil {
t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Messagebird.DefaultConfig.AccessKey != "1" {
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.DefaultConfig.AccessKey)
}
if config.Alerting.Messagebird.DefaultConfig.Originator != "31619191918" {
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.DefaultConfig.Originator)
}
if config.Alerting.Messagebird.DefaultConfig.Recipients != "31619191919" {
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.DefaultConfig.Recipients)
}
if config.Alerting.Discord == nil || config.Alerting.Discord.Validate() != nil {
t.Fatal("Discord alerting config should've been valid")
}
if config.Alerting.Discord.GetDefaultAlert() == nil {
t.Fatal("Discord.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Discord.GetDefaultAlert().FailureThreshold != 10 {
t.Errorf("Discord default alert failure threshold should've been %d, but was %d", 10, config.Alerting.Discord.GetDefaultAlert().FailureThreshold)
}
if config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 {
t.Errorf("Discord default alert success threshold should've been %d, but was %d", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold)
}
if config.Alerting.Discord.DefaultConfig.WebhookURL != "http://example.org" {
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.DefaultConfig.WebhookURL)
}
if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord {
t.Error("expected discord configuration")
}
if config.Alerting.Telegram == nil || config.Alerting.Telegram.Validate() != nil {
t.Fatal("Telegram alerting config should've been valid")
}
if config.Alerting.Telegram.GetDefaultAlert() == nil {
t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Telegram.DefaultConfig.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.DefaultConfig.Token)
}
if config.Alerting.Telegram.DefaultConfig.ID != "0123456789" {
t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.DefaultConfig.ID)
}
if config.Alerting.Twilio == nil || config.Alerting.Twilio.Validate() != nil {
t.Fatal("Twilio alerting config should've been valid")
}
if config.Alerting.Twilio.GetDefaultAlert() == nil {
t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Teams == nil || config.Alerting.Teams.Validate() != nil {
t.Fatal("Teams alerting config should've been valid")
}
if config.Alerting.Teams.GetDefaultAlert() == nil {
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
t.Fatal("Email alerting config should've been valid")
}
if config.Alerting.Email.GetDefaultAlert() == nil {
t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Email.DefaultConfig.From != "from@example.com" {
t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.From)
}
if config.Alerting.Email.DefaultConfig.Username != "from@example.com" {
t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.Username)
}
if config.Alerting.Email.DefaultConfig.Password != "hunter2" {
t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.DefaultConfig.Password)
}
if config.Alerting.Email.DefaultConfig.Host != "mail.example.com" {
t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.DefaultConfig.Host)
}
if config.Alerting.Email.DefaultConfig.Port != 587 {
t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.DefaultConfig.Port)
}
if config.Alerting.Email.DefaultConfig.To != "recipient1@example.com,recipient2@example.com" {
t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.DefaultConfig.To)
}
if config.Alerting.Email.DefaultConfig.ClientConfig == nil {
t.Fatal("Email client config should've been set")
}
if config.Alerting.Email.DefaultConfig.ClientConfig.Insecure {
t.Error("Email client config should've been secure")
}
if config.Alerting.Gotify == nil || config.Alerting.Gotify.Validate() != nil {
t.Fatal("Gotify alerting config should've been valid")
}
if config.Alerting.Gotify.GetDefaultAlert() == nil {
t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.Gotify.DefaultConfig.ServerURL != "https://gotify.example" {
t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.DefaultConfig.ServerURL)
}
if config.Alerting.Gotify.DefaultConfig.Token != "**************" {
t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.DefaultConfig.Token)
}
// External endpoints
if len(config.ExternalEndpoints) != 1 {
t.Error("There should've been 1 external endpoint")
}
if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.ExternalEndpoints[0].Alerts[0].Type)
}
if !config.ExternalEndpoints[0].Alerts[0].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 10 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.ExternalEndpoints[0].Alerts[0].FailureThreshold)
}
if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 15 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold)
}
// Endpoints
if len(config.Endpoints) != 1 {
t.Error("There should've been 1 endpoint")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
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) != 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 {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[0].Type)
}
if !config.Endpoints[0].Alerts[0].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[0].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[0].FailureThreshold)
}
if config.Endpoints[0].Alerts[0].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[0].SuccessThreshold)
}
if config.Endpoints[0].Alerts[1].Type != alert.TypePagerDuty {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePagerDuty, config.Endpoints[0].Alerts[1].Type)
}
if config.Endpoints[0].Alerts[1].GetDescription() != "default description" {
t.Errorf("The description of the alert should've been %s, but it was %s", "default description", config.Endpoints[0].Alerts[1].GetDescription())
}
if config.Endpoints[0].Alerts[1].FailureThreshold != 7 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 7, config.Endpoints[0].Alerts[1].FailureThreshold)
}
if config.Endpoints[0].Alerts[1].SuccessThreshold != 5 {
t.Errorf("The success threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[1].SuccessThreshold)
}
if config.Endpoints[0].Alerts[2].Type != alert.TypeMattermost {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMattermost, config.Endpoints[0].Alerts[2].Type)
}
if !config.Endpoints[0].Alerts[2].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[2].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[2].FailureThreshold)
}
if config.Endpoints[0].Alerts[2].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[2].SuccessThreshold)
}
if config.Endpoints[0].Alerts[3].Type != alert.TypeMessagebird {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeMessagebird, config.Endpoints[0].Alerts[3].Type)
}
if config.Endpoints[0].Alerts[3].IsEnabled() {
t.Error("The alert should've been disabled")
}
if !config.Endpoints[0].Alerts[3].IsSendingOnResolved() {
t.Error("The alert should be sending on resolve")
}
if config.Endpoints[0].Alerts[4].Type != alert.TypeDiscord {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.Endpoints[0].Alerts[4].Type)
}
if !config.Endpoints[0].Alerts[4].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[4].FailureThreshold != 10 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold)
}
if config.Endpoints[0].Alerts[4].SuccessThreshold != 8 {
t.Errorf("The default success threshold of the alert should've been %d because it was explicitly overriden, but it was %d", 8, config.Endpoints[0].Alerts[4].SuccessThreshold)
}
if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTelegram, config.Endpoints[0].Alerts[5].Type)
}
if !config.Endpoints[0].Alerts[5].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[5].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[5].FailureThreshold)
}
if config.Endpoints[0].Alerts[5].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[5].SuccessThreshold)
}
if config.Endpoints[0].Alerts[6].Type != alert.TypeTwilio {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTwilio, config.Endpoints[0].Alerts[6].Type)
}
if !config.Endpoints[0].Alerts[6].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[6].FailureThreshold != 12 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 12, config.Endpoints[0].Alerts[6].FailureThreshold)
}
if config.Endpoints[0].Alerts[6].SuccessThreshold != 15 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Endpoints[0].Alerts[6].SuccessThreshold)
}
if config.Endpoints[0].Alerts[7].Type != alert.TypeTeams {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Endpoints[0].Alerts[7].Type)
}
if !config.Endpoints[0].Alerts[7].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[7].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[7].FailureThreshold)
}
if config.Endpoints[0].Alerts[7].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[7].SuccessThreshold)
}
if config.Endpoints[0].Alerts[8].Type != alert.TypePushover {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypePushover, config.Endpoints[0].Alerts[8].Type)
}
if !config.Endpoints[0].Alerts[8].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[8].FailureThreshold != 5 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[8].FailureThreshold)
}
if config.Endpoints[0].Alerts[8].SuccessThreshold != 3 {
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.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 != 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 != 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.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")
}
if config.Endpoints[0].Alerts[10].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[10].FailureThreshold)
}
if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold)
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
slack:
webhook-url: "https://example.com"
default-alert:
enabled: true
description: "description"
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: slack
failure-threshold: 10
- type: slack
failure-threshold: 20
description: "wow"
- type: slack
enabled: false
failure-threshold: 30
provider-override:
webhook-url: https://example.com
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
// Alerting providers
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
t.Fatal("Slack alerting config should've been valid")
}
// Endpoints
if len(config.Endpoints) != 1 {
t.Error("There should've been 2 endpoints")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[0].Type)
}
if config.Endpoints[0].Alerts[1].Type != alert.TypeSlack {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[1].Type)
}
if config.Endpoints[0].Alerts[2].Type != alert.TypeSlack {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeSlack, config.Endpoints[0].Alerts[2].Type)
}
if !config.Endpoints[0].Alerts[0].IsEnabled() {
t.Error("The alert should've been enabled")
}
if !config.Endpoints[0].Alerts[1].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[2].IsEnabled() {
t.Error("The alert should've been disabled")
}
if config.Endpoints[0].Alerts[0].GetDescription() != "description" {
t.Errorf("The description of the alert should've been %s, but it was %s", "description", config.Endpoints[0].Alerts[0].GetDescription())
}
if config.Endpoints[0].Alerts[1].GetDescription() != "wow" {
t.Errorf("The description of the alert should've been %s, but it was %s", "description", config.Endpoints[0].Alerts[1].GetDescription())
}
if config.Endpoints[0].Alerts[2].GetDescription() != "description" {
t.Errorf("The description of the alert should've been %s, but it was %s", "description", config.Endpoints[0].Alerts[2].GetDescription())
}
if config.Endpoints[0].Alerts[0].FailureThreshold != 10 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[0].FailureThreshold)
}
if config.Endpoints[0].Alerts[1].FailureThreshold != 20 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 20, config.Endpoints[0].Alerts[1].FailureThreshold)
}
if config.Endpoints[0].Alerts[2].FailureThreshold != 30 {
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 30, config.Endpoints[0].Alerts[2].FailureThreshold)
}
}
func TestParseAndValidateConfigBytesWithInvalidPagerDutyAlertingConfig(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
pagerduty:
integration-key: "INVALID_KEY"
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: pagerduty
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.PagerDuty != nil {
t.Fatal("PagerDuty alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
}
}
func TestParseAndValidateConfigBytesWithInvalidPushoverAlertingConfig(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
pushover:
application-token: "INVALID_TOKEN"
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: pushover
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Pushover != nil {
t.Fatal("Pushover alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
}
}
func TestParseAndValidateConfigBytesWithCustomAlertingConfig(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
custom:
url: "https://example.com"
body: |
{
"text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
}
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: custom
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("Config shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("Custom alerting config shouldn't have been nil")
}
if err = config.Alerting.Custom.Validate(); err != nil {
t.Fatal("Custom alerting config should've been valid")
}
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{ProviderOverride: map[string]any{"client": map[string]any{"insecure": true}}})
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true))
}
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "TRIGGERED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false))
}
if !cfg.ClientConfig.Insecure {
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, cfg.ClientConfig.Insecure)
}
}
func TestParseAndValidateConfigBytesWithCustomAlertingConfigAndCustomPlaceholderValues(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
custom:
placeholders:
ALERT_TRIGGERED_OR_RESOLVED:
TRIGGERED: "partial_outage"
RESOLVED: "operational"
url: "https://example.com"
insecure: true
body: "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: custom
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("Custom alerting config shouldn't have been nil")
}
if err = config.Alerting.Custom.Validate(); err != nil {
t.Fatal("Custom alerting config should've been valid")
}
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "operational" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'")
}
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
}
}
func TestParseAndValidateConfigBytesWithCustomAlertingConfigAndOneCustomPlaceholderValue(t *testing.T) {
config, err := parseAndValidateConfigBytes([]byte(`
alerting:
custom:
placeholders:
ALERT_TRIGGERED_OR_RESOLVED:
TRIGGERED: "partial_outage"
url: "https://example.com"
body: "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]"
endpoints:
- name: website
url: https://twin.sh/health
alerts:
- type: custom
conditions:
- "[STATUS] == 200"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Alerting == nil {
t.Fatal("config.Alerting shouldn't have been nil")
}
if config.Alerting.Custom == nil {
t.Fatal("Custom alerting config shouldn't have been nil")
}
if err := config.Alerting.Custom.Validate(); err != nil {
t.Fatal("Custom alerting config should've been valid")
}
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'")
}
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
}
}
func TestParseAndValidateConfigBytesWithInvalidEndpointName(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
endpoints:
- name: ""
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
if err == nil {
t.Error("should've returned an error")
}
}
func TestParseAndValidateConfigBytesWithDuplicateEndpointName(t *testing.T) {
scenarios := []struct {
name string
shouldError bool
config string
}{
{
name: "same-name-no-group",
shouldError: true,
config: `
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-different-group",
shouldError: false,
config: `
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- name: ep1
group: g1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-same-group",
shouldError: true,
config: `
endpoints:
- name: ep1
group: g1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
- name: ep1
group: g1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-different-endpoint-type",
shouldError: true,
config: `
external-endpoints:
- name: ep1
token: "12345678"
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "same-name-different-group-different-endpoint-type",
shouldError: false,
config: `
external-endpoints:
- name: ep1
group: gr1
token: "12345678"
endpoints:
- name: ep1
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
if scenario.shouldError && err == nil {
t.Error("should've returned an error")
} else if !scenario.shouldError && err != nil {
t.Error("shouldn't have returned an error")
}
})
}
}
func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
storage:
type: sqlite
endpoints:
- name: example
url: https://example.org
conditions:
- "[STATUS] == 200"
`))
if err == nil {
t.Error("should've returned an error, because a file must be specified for a storage of type sqlite")
}
}
func TestParseAndValidateConfigBytesWithInvalidYAML(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
storage:
invalid yaml
endpoints:
- name: example
url: https://example.org
conditions:
- "[STATUS] == 200"
`))
if err == nil {
t.Error("should've returned an error")
}
}
func TestParseAndValidateConfigBytesWithInvalidSecurityConfig(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
security:
basic:
username: "admin"
password-sha512: "invalid-sha512-hash"
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`))
if err == nil {
t.Error("should've returned an error")
}
}
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
const expectedUsername = "admin"
const expectedPasswordHash = "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
security:
basic:
username: "%s"
password-bcrypt-base64: "%s"
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`, expectedUsername, expectedPasswordHash)))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Security == nil {
t.Fatal("config.Security shouldn't have been nil")
}
if !config.Security.ValidateAndSetDefaults() {
t.Error("Security config should've been valid")
}
if config.Security.Basic == nil {
t.Fatal("config.Security.Basic shouldn't have been nil")
}
if config.Security.Basic.Username != expectedUsername {
t.Errorf("config.Security.Basic.Username should've been %s, but was %s", expectedUsername, config.Security.Basic.Username)
}
if config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash {
t.Errorf("config.Security.Basic.PasswordBcryptHashBase64Encoded should've been %s, but was %s", expectedPasswordHash, config.Security.Basic.PasswordBcryptHashBase64Encoded)
}
}
func TestParseAndValidateConfigBytesWithLiteralDollarSign(t *testing.T) {
os.Setenv("GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign", "whatever")
config, err := parseAndValidateConfigBytes([]byte(`
endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[BODY] == $$GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign"
- "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign"
`))
if err != nil {
t.Error("expected no error, got", err.Error())
}
if config == nil {
t.Fatal("DefaultConfig shouldn't have been nil")
}
if config.Endpoints[0].URL != "https://twin.sh/health" {
t.Errorf("URL should have been %s", "https://twin.sh/health")
}
if config.Endpoints[0].Conditions[0] != "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign" {
t.Errorf("Condition should have been %s", "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign")
}
if config.Endpoints[0].Conditions[1] != "[BODY] == whatever" {
t.Errorf("Condition should have been %s", "[BODY] == whatever")
}
}
func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(``))
if !errors.Is(err, ErrNoEndpointOrSuiteInConfig) {
t.Error("The error returned should have been of type ErrNoEndpointOrSuiteInConfig")
}
}
func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{
AWSSimpleEmailService: &awsses.AlertProvider{},
ClickUp: &clickup.AlertProvider{},
Custom: &custom.AlertProvider{},
Datadog: &datadog.AlertProvider{},
Discord: &discord.AlertProvider{},
Email: &email.AlertProvider{},
Gitea: &gitea.AlertProvider{},
GitHub: &github.AlertProvider{},
GitLab: &gitlab.AlertProvider{},
GoogleChat: &googlechat.AlertProvider{},
Gotify: &gotify.AlertProvider{},
HomeAssistant: &homeassistant.AlertProvider{},
IFTTT: &ifttt.AlertProvider{},
Ilert: &ilert.AlertProvider{},
IncidentIO: &incidentio.AlertProvider{},
Line: &line.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
Messagebird: &messagebird.AlertProvider{},
NewRelic: &newrelic.AlertProvider{},
Ntfy: &ntfy.AlertProvider{},
Opsgenie: &opsgenie.AlertProvider{},
PagerDuty: &pagerduty.AlertProvider{},
Plivo: &plivo.AlertProvider{},
Pushover: &pushover.AlertProvider{},
RocketChat: &rocketchat.AlertProvider{},
SendGrid: &sendgrid.AlertProvider{},
Signal: &signal.AlertProvider{},
SIGNL4: &signl4.AlertProvider{},
Slack: &slack.AlertProvider{},
Splunk: &splunk.AlertProvider{},
Squadcast: &squadcast.AlertProvider{},
Telegram: &telegram.AlertProvider{},
Teams: &teams.AlertProvider{},
TeamsWorkflows: &teamsworkflows.AlertProvider{},
Twilio: &twilio.AlertProvider{},
Vonage: &vonage.AlertProvider{},
Webex: &webex.AlertProvider{},
Zapier: &zapier.AlertProvider{},
Zulip: &zulip.AlertProvider{},
}
scenarios := []struct {
alertType alert.Type
expected provider.AlertProvider
}{
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
{alertType: alert.TypeClickUp, expected: alertingConfig.ClickUp},
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
{alertType: alert.TypeDatadog, expected: alertingConfig.Datadog},
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
{alertType: alert.TypeGitea, expected: alertingConfig.Gitea},
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
{alertType: alert.TypeGitLab, expected: alertingConfig.GitLab},
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
{alertType: alert.TypeHomeAssistant, expected: alertingConfig.HomeAssistant},
{alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},
{alertType: alert.TypeIlert, expected: alertingConfig.Ilert},
{alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},
{alertType: alert.TypeLine, expected: alertingConfig.Line},
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},
{alertType: alert.TypeNewRelic, expected: alertingConfig.NewRelic},
{alertType: alert.TypeNtfy, expected: alertingConfig.Ntfy},
{alertType: alert.TypeOpsgenie, expected: alertingConfig.Opsgenie},
{alertType: alert.TypePagerDuty, expected: alertingConfig.PagerDuty},
{alertType: alert.TypePlivo, expected: alertingConfig.Plivo},
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
{alertType: alert.TypeRocketChat, expected: alertingConfig.RocketChat},
{alertType: alert.TypeSendGrid, expected: alertingConfig.SendGrid},
{alertType: alert.TypeSignal, expected: alertingConfig.Signal},
{alertType: alert.TypeSIGNL4, expected: alertingConfig.SIGNL4},
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
{alertType: alert.TypeSplunk, expected: alertingConfig.Splunk},
{alertType: alert.TypeSquadcast, expected: alertingConfig.Squadcast},
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
{alertType: alert.TypeVonage, expected: alertingConfig.Vonage},
{alertType: alert.TypeWebex, expected: alertingConfig.Webex},
{alertType: alert.TypeZapier, expected: alertingConfig.Zapier},
{alertType: alert.TypeZulip, expected: alertingConfig.Zulip},
}
for _, scenario := range scenarios {
t.Run(string(scenario.alertType), func(t *testing.T) {
if alertingConfig.GetAlertingProviderByAlertType(scenario.alertType) != scenario.expected {
t.Errorf("expected %s configuration", scenario.alertType)
}
})
}
}
func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
tests := []struct {
name string
config *Config
expected []string
}{
{
name: "no-endpoints",
config: &Config{
Endpoints: []*endpoint.Endpoint{},
},
expected: []string{},
},
{
name: "single-endpoint-no-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
},
},
},
expected: []string{},
},
{
name: "single-endpoint-with-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
},
},
},
},
expected: []string{"env", "team"},
},
{
name: "multiple-endpoints-with-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
"module": "auth",
},
},
{
Name: "endpoint2",
URL: "https://example.org",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "staging",
"team": "frontend",
},
},
},
},
expected: []string{"env", "team", "module"},
},
{
name: "multiple-endpoints-with-some-disabled",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
},
},
{
Name: "endpoint2",
URL: "https://example.org",
Enabled: toPtr(false),
ExtraLabels: map[string]string{
"module": "auth",
},
},
},
},
expected: []string{"env", "team"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
labels := tt.config.GetUniqueExtraMetricLabels()
if len(labels) != len(tt.expected) {
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
}
for _, label := range tt.expected {
if !slices.Contains(labels, label) {
t.Errorf("expected label %s to be present", label)
}
}
})
}
}
func TestParseAndValidateConfigBytesWithDuplicateKeysAcrossEntityTypes(t *testing.T) {
scenarios := []struct {
name string
shouldError bool
expectedErr string
config string
}{
{
name: "endpoint-suite-same-key",
shouldError: true,
expectedErr: "duplicate key 'backend_test-api': suite 'backend_test-api' conflicts with endpoint 'backend_test-api'",
config: `
endpoints:
- name: test-api
group: backend
url: https://example.com/api
conditions:
- "[STATUS] == 200"
suites:
- name: test-api
group: backend
interval: 30s
endpoints:
- name: step1
url: https://example.com/test
conditions:
- "[STATUS] == 200"`,
},
{
name: "endpoint-suite-different-keys",
shouldError: false,
config: `
endpoints:
- name: api-service
group: backend
url: https://example.com/api
conditions:
- "[STATUS] == 200"
suites:
- name: integration-tests
group: testing
interval: 30s
endpoints:
- name: step1
url: https://example.com/test
conditions:
- "[STATUS] == 200"`,
},
{
name: "endpoint-external-endpoint-suite-unique-keys",
shouldError: false,
config: `
endpoints:
- name: api-service
group: backend
url: https://example.com/api
conditions:
- "[STATUS] == 200"
external-endpoints:
- name: monitoring-agent
group: infrastructure
token: "secret-token"
heartbeat:
interval: 5m
suites:
- name: integration-tests
group: testing
interval: 30s
endpoints:
- name: step1
url: https://example.com/test
conditions:
- "[STATUS] == 200"`,
},
{
name: "suite-with-same-key-as-external-endpoint",
shouldError: true,
expectedErr: "duplicate key 'monitoring_health-check': suite 'monitoring_health-check' conflicts with external endpoint 'monitoring_health-check'",
config: `
endpoints:
- name: dummy
url: https://example.com/dummy
conditions:
- "[STATUS] == 200"
external-endpoints:
- name: health-check
group: monitoring
token: "secret-token"
heartbeat:
interval: 5m
suites:
- name: health-check
group: monitoring
interval: 30s
endpoints:
- name: step1
url: https://example.com/test
conditions:
- "[STATUS] == 200"`,
},
{
name: "endpoint-with-same-name-as-suite-endpoint-different-groups",
shouldError: false,
config: `
endpoints:
- name: api-health
group: backend
url: https://example.com/health
conditions:
- "[STATUS] == 200"
suites:
- name: integration-suite
group: testing
interval: 30s
endpoints:
- name: api-health
url: https://example.com/api/health
conditions:
- "[STATUS] == 200"`,
},
{
name: "endpoint-conflicting-with-suite-endpoint",
shouldError: true,
expectedErr: "duplicate key 'backend_api-health': endpoint 'backend_api-health' in suite 'backend_integration-suite' conflicts with endpoint 'backend_api-health'",
config: `
endpoints:
- name: api-health
group: backend
url: https://example.com/health
conditions:
- "[STATUS] == 200"
suites:
- name: integration-suite
group: backend
interval: 30s
endpoints:
- name: api-health
url: https://example.com/api/health
conditions:
- "[STATUS] == 200"`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
if scenario.shouldError {
if err == nil {
t.Error("should've returned an error")
} else if scenario.expectedErr != "" && err.Error() != scenario.expectedErr {
t.Errorf("expected error message '%s', got '%s'", scenario.expectedErr, err.Error())
}
} else if err != nil {
t.Errorf("shouldn't have returned an error, got: %v", err)
}
})
}
}
func TestParseAndValidateConfigBytesWithSuites(t *testing.T) {
scenarios := []struct {
name string
shouldError bool
expectedErr string
config string
}{
{
name: "suite-with-no-name",
shouldError: true,
expectedErr: "invalid suite 'testing_': suite must have a name",
config: `
endpoints:
- name: dummy
url: https://example.com/dummy
conditions:
- "[STATUS] == 200"
suites:
- group: testing
interval: 30s
endpoints:
- name: step1
url: https://example.com/test
conditions:
- "[STATUS] == 200"`,
},
{
name: "suite-with-no-endpoints",
shouldError: true,
expectedErr: "invalid suite 'testing_empty-suite': suite must have at least one endpoint",
config: `
endpoints:
- name: dummy
url: https://example.com/dummy
conditions:
- "[STATUS] == 200"
suites:
- name: empty-suite
group: testing
interval: 30s
endpoints: []`,
},
{
name: "suite-with-duplicate-endpoint-names",
shouldError: true,
expectedErr: "invalid suite 'testing_duplicate-test': suite cannot have duplicate endpoint names: duplicate endpoint name 'step1'",
config: `
endpoints:
- name: dummy
url: https://example.com/dummy
conditions:
- "[STATUS] == 200"
suites:
- name: duplicate-test
group: testing
interval: 30s
endpoints:
- name: step1
url: https://example.com/test1
conditions:
- "[STATUS] == 200"
- name: step1
url: https://example.com/test2
conditions:
- "[STATUS] == 200"`,
},
{
name: "suite-with-invalid-negative-timeout",
shouldError: true,
expectedErr: "invalid suite 'testing_negative-timeout-suite': suite timeout must be positive",
config: `
endpoints:
- name: dummy
url: https://example.com/dummy
conditions:
- "[STATUS] == 200"
suites:
- name: negative-timeout-suite
group: testing
interval: 30s
timeout: -5m
endpoints:
- name: step1
url: https://example.com/test
conditions:
- "[STATUS] == 200"`,
},
{
name: "valid-suite-with-defaults",
shouldError: false,
config: `
endpoints:
- name: api-service
group: backend
url: https://example.com/api
conditions:
- "[STATUS] == 200"
suites:
- name: integration-test
group: testing
endpoints:
- name: step1
url: https://example.com/test
conditions:
- "[STATUS] == 200"
- name: step2
url: https://example.com/validate
conditions:
- "[STATUS] == 200"`,
},
{
name: "valid-suite-with-all-fields",
shouldError: false,
config: `
endpoints:
- name: api-service
group: backend
url: https://example.com/api
conditions:
- "[STATUS] == 200"
suites:
- name: full-integration-test
group: testing
enabled: true
interval: 15m
timeout: 10m
context:
base_url: "https://example.com"
user_id: 12345
endpoints:
- name: authentication
url: https://example.com/auth
conditions:
- "[STATUS] == 200"
- name: user-profile
url: https://example.com/profile
conditions:
- "[STATUS] == 200"
- "[BODY].user_id == 12345"`,
},
{
name: "valid-suite-with-endpoint-inheritance",
shouldError: false,
config: `
endpoints:
- name: api-service
group: backend
url: https://example.com/api
conditions:
- "[STATUS] == 200"
suites:
- name: inheritance-test
group: parent-group
endpoints:
- name: child-endpoint
url: https://example.com/test
conditions:
- "[STATUS] == 200"`,
},
{
name: "valid-suite-with-store-functionality",
shouldError: false,
config: `
endpoints:
- name: api-service
group: backend
url: https://example.com/api
conditions:
- "[STATUS] == 200"
suites:
- name: store-test
group: testing
endpoints:
- name: get-token
url: https://example.com/auth
conditions:
- "[STATUS] == 200"
store:
auth_token: "[BODY].token"
- name: use-token
url: https://example.com/data
headers:
Authorization: "Bearer {auth_token}"
conditions:
- "[STATUS] == 200"`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
if scenario.shouldError {
if err == nil {
t.Error("should've returned an error")
} else if scenario.expectedErr != "" && err.Error() != scenario.expectedErr {
t.Errorf("expected error message '%s', got '%s'", scenario.expectedErr, err.Error())
}
} else if err != nil {
t.Errorf("shouldn't have returned an error, got: %v", err)
}
})
}
}
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)
}
})
}
}
================================================
FILE: config/connectivity/connectivity.go
================================================
package connectivity
import (
"errors"
"strings"
"time"
"github.com/TwiN/gatus/v5/client"
)
var (
ErrInvalidInterval = errors.New("connectivity.checker.interval must be 5s or higher")
ErrInvalidDNSTarget = errors.New("connectivity.checker.target must be suffixed with :53")
)
// Config is the configuration for the connectivity checker.
type Config struct {
Checker *Checker `yaml:"checker,omitempty"`
}
func (c *Config) ValidateAndSetDefaults() error {
if c.Checker != nil {
if c.Checker.Interval == 0 {
c.Checker.Interval = 60 * time.Second
} else if c.Checker.Interval < 5*time.Second {
return ErrInvalidInterval
}
if !strings.HasSuffix(c.Checker.Target, ":53") {
return ErrInvalidDNSTarget
}
}
return nil
}
// Checker is the configuration for making sure Gatus has access to the internet.
type Checker struct {
Target string `yaml:"target"` // e.g. 1.1.1.1:53
Interval time.Duration `yaml:"interval,omitempty"`
isConnected bool
lastCheck time.Time
}
func (c *Checker) Check() bool {
connected, _ := client.CanCreateNetworkConnection("tcp", c.Target, "", &client.Config{Timeout: 5 * time.Second})
return connected
}
func (c *Checker) IsConnected() bool {
if now := time.Now(); now.After(c.lastCheck.Add(c.Interval)) {
c.lastCheck, c.isConnected = now, c.Check()
}
return c.isConnected
}
================================================
FILE: config/connectivity/connectivity_test.go
================================================
package connectivity
import (
"fmt"
"testing"
"time"
)
func TestConfig(t *testing.T) {
scenarios := []struct {
name string
cfg *Config
expectedErr error
expectedInterval time.Duration
}{
{
name: "good-config",
cfg: &Config{Checker: &Checker{Target: "1.1.1.1:53", Interval: 10 * time.Second}},
expectedInterval: 10 * time.Second,
},
{
name: "good-config-with-default-interval",
cfg: &Config{Checker: &Checker{Target: "8.8.8.8:53", Interval: 0}},
expectedInterval: 60 * time.Second,
},
{
name: "config-with-interval-too-low",
cfg: &Config{Checker: &Checker{Target: "1.1.1.1:53", Interval: 4 * time.Second}},
expectedErr: ErrInvalidInterval,
},
{
name: "config-with-invalid-target-due-to-missing-port",
cfg: &Config{Checker: &Checker{Target: "1.1.1.1", Interval: 15 * time.Second}},
expectedErr: ErrInvalidDNSTarget,
},
{
name: "config-with-invalid-target-due-to-invalid-dns-port",
cfg: &Config{Checker: &Checker{Target: "1.1.1.1:52", Interval: 15 * time.Second}},
expectedErr: ErrInvalidDNSTarget,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", scenario.expectedErr) {
t.Errorf("expected error %v, got %v", scenario.expectedErr, err)
}
if err == nil && scenario.expectedErr == nil {
if scenario.cfg.Checker.Interval != scenario.expectedInterval {
t.Errorf("expected interval %v, got %v", scenario.expectedInterval, scenario.cfg.Checker.Interval)
}
}
})
}
}
func TestChecker_IsConnected(t *testing.T) {
checker := &Checker{Target: "1.1.1.1:53", Interval: 10 * time.Second}
if !checker.IsConnected() {
t.Error("expected checker.IsConnected() to be true")
}
}
================================================
FILE: config/endpoint/common.go
================================================
package endpoint
import (
"errors"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
)
var (
// ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name
ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint")
// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't
ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\")
)
// validateEndpointNameGroupAndAlerts validates the name, group and alerts of an endpoint
func validateEndpointNameGroupAndAlerts(name, group string, alerts []*alert.Alert) error {
if len(name) == 0 {
return ErrEndpointWithNoName
}
if strings.ContainsAny(name, "\"\\") || strings.ContainsAny(group, "\"\\") {
return ErrEndpointWithInvalidNameOrGroup
}
for _, endpointAlert := range alerts {
if err := endpointAlert.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
================================================
FILE: config/endpoint/common_test.go
================================================
package endpoint
import (
"errors"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
)
func TestValidateEndpointNameGroupAndAlerts(t *testing.T) {
scenarios := []struct {
name string
group string
alerts []*alert.Alert
expectedErr error
}{
{
name: "n",
group: "g",
alerts: []*alert.Alert{{Type: "slack"}},
},
{
name: "n",
alerts: []*alert.Alert{{Type: "slack"}},
},
{
group: "g",
alerts: []*alert.Alert{{Type: "slack"}},
expectedErr: ErrEndpointWithNoName,
},
{
name: "\"",
alerts: []*alert.Alert{{Type: "slack"}},
expectedErr: ErrEndpointWithInvalidNameOrGroup,
},
{
name: "n",
group: "\\",
alerts: []*alert.Alert{{Type: "slack"}},
expectedErr: ErrEndpointWithInvalidNameOrGroup,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := validateEndpointNameGroupAndAlerts(scenario.name, scenario.group, scenario.alerts)
if !errors.Is(err, scenario.expectedErr) {
t.Errorf("expected error to be %v but got %v", scenario.expectedErr, err)
}
})
}
}
================================================
FILE: config/endpoint/condition.go
================================================
package endpoint
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/config/gontext"
"github.com/TwiN/gatus/v5/pattern"
)
const (
// maximumLengthBeforeTruncatingWhenComparedWithPattern is the maximum length an element being compared to a
// pattern can have.
//
// This is only used for aesthetic purposes; it does not influence whether the condition evaluation results in a
// success or a failure
maximumLengthBeforeTruncatingWhenComparedWithPattern = 25
)
// Condition is a condition that needs to be met in order for an Endpoint to be considered healthy.
type Condition string
// Validate checks if the Condition is valid
func (c Condition) Validate() error {
r := &Result{}
c.evaluate(r, false, false, nil)
if len(r.Errors) != 0 {
return errors.New(r.Errors[0])
}
return nil
}
// evaluate the Condition with the Result and an optional context
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, resolveSuccessfulConditions bool, context *gontext.Gontext) bool {
condition := string(c)
success := false
conditionToDisplay := condition
shouldResolveCondition := func(success bool) bool {
if success {
return resolveSuccessfulConditions
}
return !dontResolveFailedConditions
}
if strings.Contains(condition, " == ") {
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context)
success = isEqual(resolvedParameters[0], resolvedParameters[1])
if shouldResolveCondition(success) {
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
}
} else if strings.Contains(condition, " != ") {
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " != "), result, context)
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
if shouldResolveCondition(success) {
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
}
} else if strings.Contains(condition, " <= ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " <= "), result, context)
success = resolvedParameters[0] <= resolvedParameters[1]
if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
}
} else if strings.Contains(condition, " >= ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " >= "), result, context)
success = resolvedParameters[0] >= resolvedParameters[1]
if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
}
} else if strings.Contains(condition, " > ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " > "), result, context)
success = resolvedParameters[0] > resolvedParameters[1]
if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
}
} else if strings.Contains(condition, " < ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " < "), result, context)
success = resolvedParameters[0] < resolvedParameters[1]
if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
}
} else {
result.AddError(fmt.Sprintf("invalid condition: %s", condition))
return false
}
if !success {
//logr.Debugf("[Condition.evaluate] Condition '%s' did not succeed because '%s' is false", condition, condition)
}
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
return success
}
// hasBodyPlaceholder checks whether the condition has a BodyPlaceholder
// Used for determining whether the response body should be read or not
func (c Condition) hasBodyPlaceholder() bool {
return strings.Contains(string(c), BodyPlaceholder)
}
// hasDomainExpirationPlaceholder checks whether the condition has a DomainExpirationPlaceholder
// Used for determining whether a whois operation is necessary
func (c Condition) hasDomainExpirationPlaceholder() bool {
return strings.Contains(string(c), DomainExpirationPlaceholder)
}
// hasIPPlaceholder checks whether the condition has an IPPlaceholder
// Used for determining whether an IP lookup is necessary
func (c Condition) hasIPPlaceholder() bool {
return strings.Contains(string(c), IPPlaceholder)
}
// isEqual compares two strings.
//
// Supports the "pat" and the "any" functions.
// i.e. if one of the parameters starts with PatternFunctionPrefix and ends with FunctionSuffix, it will be treated like
// a pattern.
func isEqual(first, second string) bool {
firstHasFunctionSuffix := strings.HasSuffix(first, FunctionSuffix)
secondHasFunctionSuffix := strings.HasSuffix(second, FunctionSuffix)
if firstHasFunctionSuffix || secondHasFunctionSuffix {
var isFirstPattern, isSecondPattern bool
if strings.HasPrefix(first, PatternFunctionPrefix) && firstHasFunctionSuffix {
isFirstPattern = true
first = strings.TrimSuffix(strings.TrimPrefix(first, PatternFunctionPrefix), FunctionSuffix)
}
if strings.HasPrefix(second, PatternFunctionPrefix) && secondHasFunctionSuffix {
isSecondPattern = true
second = strings.TrimSuffix(strings.TrimPrefix(second, PatternFunctionPrefix), FunctionSuffix)
}
if isFirstPattern && !isSecondPattern {
return pattern.Match(first, second)
} else if !isFirstPattern && isSecondPattern {
return pattern.Match(second, first)
}
var isFirstAny, isSecondAny bool
if strings.HasPrefix(first, AnyFunctionPrefix) && firstHasFunctionSuffix {
isFirstAny = true
first = strings.TrimSuffix(strings.TrimPrefix(first, AnyFunctionPrefix), FunctionSuffix)
}
if strings.HasPrefix(second, AnyFunctionPrefix) && secondHasFunctionSuffix {
isSecondAny = true
second = strings.TrimSuffix(strings.TrimPrefix(second, AnyFunctionPrefix), FunctionSuffix)
}
if isFirstAny && !isSecondAny {
options := strings.Split(first, ",")
for _, option := range options {
if strings.TrimSpace(option) == second {
return true
}
}
return false
} else if !isFirstAny && isSecondAny {
options := strings.Split(second, ",")
for _, option := range options {
if strings.TrimSpace(option) == first {
return true
}
}
return false
}
}
// test if inputs are integers
firstInt, err1 := strconv.ParseInt(first, 0, 64)
secondInt, err2 := strconv.ParseInt(second, 0, 64)
if err1 == nil && err2 == nil {
return firstInt == secondInt
}
return first == second
}
// sanitizeAndResolveWithContext sanitizes and resolves a list of elements with an optional context
func sanitizeAndResolveWithContext(elements []string, result *Result, context *gontext.Gontext) ([]string, []string) {
parameters := make([]string, len(elements))
resolvedParameters := make([]string, len(elements))
for i, element := range elements {
element = strings.TrimSpace(element)
parameters[i] = element
// Use the unified ResolvePlaceholder function
resolved, err := ResolvePlaceholder(element, result, context)
if err != nil {
// If there's an error, add it to the result
result.AddError(err.Error())
resolvedParameters[i] = element + " " + InvalidConditionElementSuffix
} else {
resolvedParameters[i] = resolved
}
}
return parameters, resolvedParameters
}
func sanitizeAndResolveNumericalWithContext(list []string, result *Result, context *gontext.Gontext) (parameters []string, resolvedNumericalParameters []int64) {
parameters, resolvedParameters := sanitizeAndResolveWithContext(list, result, context)
for _, element := range resolvedParameters {
if duration, err := time.ParseDuration(element); duration != 0 && err == nil {
// If the string is a duration, convert it to milliseconds
resolvedNumericalParameters = append(resolvedNumericalParameters, duration.Milliseconds())
} else if number, err := strconv.ParseInt(element, 0, 64); err != nil {
// It's not an int, so we'll check if it's a float
if f, err := strconv.ParseFloat(element, 64); err == nil {
// It's a float, but we'll convert it to an int. We're losing precision here, but it's better than
// just returning 0.
resolvedNumericalParameters = append(resolvedNumericalParameters, int64(f))
} else {
// Default to 0 if the string couldn't be converted to an integer or a float
resolvedNumericalParameters = append(resolvedNumericalParameters, 0)
}
} else {
resolvedNumericalParameters = append(resolvedNumericalParameters, number)
}
}
return parameters, resolvedNumericalParameters
}
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
resolvedStrings := make([]string, 2)
for i := range 2 {
// Check if the parameter is a certificate or domain expiration placeholder
if parameters[i] == CertificateExpirationPlaceholder || parameters[i] == DomainExpirationPlaceholder {
// Format as duration string (convert milliseconds back to duration)
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
resolvedStrings[i] = formatDuration(duration)
} else if _, err := time.ParseDuration(parameters[i]); err == nil {
// If the original parameter was a duration string (like "48h"), format the resolved value
// as a duration string too so it matches and doesn't show in parentheses
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
resolvedStrings[i] = formatDuration(duration)
} else {
// Format as integer
resolvedStrings[i] = strconv.Itoa(int(resolvedParameters[i]))
}
}
return prettify(parameters, resolvedStrings, operator)
}
// formatDuration formats a duration in a clean, human-readable way by removing unnecessary zero components.
// For example: 336h0m0s becomes 336h, 1h30m0s becomes 1h30m, but 1h0m15s stays as 1h0m15s.
// Truncates to whole seconds to avoid decimal values like 7353h5m54.67s.
func formatDuration(d time.Duration) string {
// Truncate to whole seconds to avoid decimal seconds
d = d.Truncate(time.Second)
s := d.String()
// Special case: if duration is zero, return "0s"
if s == "0s" {
return "0s"
}
// Remove trailing "0s" if present
if strings.HasSuffix(s, "0s") {
s = strings.TrimSuffix(s, "0s")
// Remove trailing "0m" if present after removing "0s"
s = strings.TrimSuffix(s, "0m")
}
return s
}
// prettify returns a string representation of a condition with its parameters resolved between parentheses
func prettify(parameters []string, resolvedParameters []string, operator string) string {
// 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])
}
// 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
}
// 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
}
return left + " " + operator + " " + right
}
================================================
FILE: config/endpoint/condition_bench_test.go
================================================
package endpoint
import (
"testing"
)
func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
condition := Condition("[BODY].user.name == bob.doe")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 200}
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 400}
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
================================================
FILE: config/endpoint/condition_result.go
================================================
package endpoint
// ConditionResult result of a Condition
type ConditionResult struct {
// Condition that was evaluated
Condition string `json:"condition"`
// Success whether the condition was met (successful) or not (failed)
Success bool `json:"success"`
}
================================================
FILE: config/endpoint/condition_test.go
================================================
package endpoint
import (
"errors"
"fmt"
"strconv"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/gontext"
)
func TestCondition_Validate(t *testing.T) {
scenarios := []struct {
condition Condition
expectedErr error
}{
{condition: "[STATUS] == 200", expectedErr: nil},
{condition: "[STATUS] != 200", expectedErr: nil},
{condition: "[STATUS] <= 200", expectedErr: nil},
{condition: "[STATUS] >= 200", expectedErr: nil},
{condition: "[STATUS] < 200", expectedErr: nil},
{condition: "[STATUS] > 200", expectedErr: nil},
{condition: "[STATUS] == any(200, 201, 202, 203)", expectedErr: nil},
{condition: "[STATUS] == [BODY].status", expectedErr: nil},
{condition: "[CONNECTED] == true", expectedErr: nil},
{condition: "[RESPONSE_TIME] < 500", expectedErr: nil},
{condition: "[IP] == 127.0.0.1", expectedErr: nil},
{condition: "[BODY] == 1", expectedErr: nil},
{condition: "[BODY].test == wat", expectedErr: nil},
{condition: "[BODY].test.wat == wat", expectedErr: nil},
{condition: "[BODY].age == [BODY].id", expectedErr: nil},
{condition: "[BODY].users[0].id == 1", expectedErr: nil},
{condition: "len([BODY].users) == 100", expectedErr: nil},
{condition: "len([BODY].data) < 5", expectedErr: nil},
{condition: "has([BODY].errors) == false", expectedErr: nil},
{condition: "has([BODY].users[0].name) == true", expectedErr: nil},
{condition: "[BODY].name == pat(john*)", expectedErr: nil},
{condition: "[CERTIFICATE_EXPIRATION] > 48h", expectedErr: nil},
{condition: "[DOMAIN_EXPIRATION] > 720h", expectedErr: nil},
{condition: "raw == raw", expectedErr: nil},
{condition: "[STATUS] ? 201", expectedErr: errors.New("invalid condition: [STATUS] ? 201")},
{condition: "[STATUS]==201", expectedErr: errors.New("invalid condition: [STATUS]==201")},
{condition: "[STATUS] = = 201", expectedErr: errors.New("invalid condition: [STATUS] = = 201")},
{condition: "[STATUS] ==", expectedErr: errors.New("invalid condition: [STATUS] ==")},
{condition: "[STATUS]", expectedErr: errors.New("invalid condition: [STATUS]")},
// FIXME: Should return an error, but doesn't because jsonpath isn't evaluated due to body being empty in Condition.Validate()
//{condition: "len([BODY].users == 100", expectedErr: nil},
}
for _, scenario := range scenarios {
t.Run(string(scenario.condition), func(t *testing.T) {
if err := scenario.condition.Validate(); fmt.Sprint(err) != fmt.Sprint(scenario.expectedErr) {
t.Errorf("expected err %v, got %v", scenario.expectedErr, err)
}
})
}
}
func TestCondition_evaluate(t *testing.T) {
scenarios := []struct {
Name string
Condition Condition
Result *Result
DontResolveFailedConditions bool
ResolveSuccessfulConditions bool
ExpectedSuccess bool
ExpectedOutput string
}{
{
Name: "ip",
Condition: Condition("[IP] == 127.0.0.1"),
Result: &Result{IP: "127.0.0.1"},
ExpectedSuccess: true,
ExpectedOutput: "[IP] == 127.0.0.1",
},
{
Name: "status",
Condition: Condition("[STATUS] == 200"),
Result: &Result{HTTPStatus: 200},
ExpectedSuccess: true,
ExpectedOutput: "[STATUS] == 200",
},
{
Name: "status-failure",
Condition: Condition("[STATUS] == 200"),
Result: &Result{HTTPStatus: 500},
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] (500) == 200",
},
{
Name: "status-using-less-than",
Condition: Condition("[STATUS] < 300"),
Result: &Result{HTTPStatus: 201},
ExpectedSuccess: true,
ExpectedOutput: "[STATUS] < 300",
},
{
Name: "status-using-less-than-failure",
Condition: Condition("[STATUS] < 300"),
Result: &Result{HTTPStatus: 404},
ExpectedSuccess: false,
ExpectedOutput: "[STATUS] (404) < 300",
},
{
Name: "response-time-using-less-than",
Condition: Condition("[RESPONSE_TIME] < 500"),
Result: &Result{Duration: 50 * time.Millisecond},
ExpectedSuccess: true,
ExpectedOutput: "[RESPONSE_TIME] < 500",
},
{
Name: "response-time-using-less-than-with-duration",
Condition: Condition("[RESPONSE_TIME] < 1s"),
Result: &Result{Duration: 50 * time.Millisecond},
ExpectedSuccess: true,
ExpectedOutput: "[RESPONSE_TIME] < 1s",
},
{
Name: "response-time-using-less-than-invalid",
Condition: Condition("[RESPONSE_TIME] < potato"),
Result: &Result{Duration: 50 * time.Millisecond},
ExpectedSuccess: false,
ExpectedOutput: "[RESPONSE_TIME] (50) < potato (0)", // Non-numerical values automatically resolve to 0
},
{
Name: "response-time-using-greater-than",
Condition: Condition("[RESPONSE_TIME] > 500"),
Result: &Result{Duration: 750 * time.Millisecond},
ExpectedSuccess: true,
ExpectedOutput: "[RESPONSE_TIME] > 500",
},
{
Name: "response-time-using-greater-than-with-duration",
Condition: Condition("[RESPONSE_TIME] > 1s"),
Result: &Result{Duration: 2 * time.Second},
ExpectedSuccess: true,
ExpectedOutput: "[RESPONSE_TIME] > 1s",
},
{
Name: "response-time-using-greater-than-or-equal-to-equal",
Condition: Condition("[RESPONSE_TIME] >= 500"),
Result: &Result{Duration: 500 * time.Millisecond},
ExpectedSuccess: true,
ExpectedOutput: "[RESPONSE_TIME] >= 500",
},
{
Name: "response-time-using-greater-than-or-equal-to-greater",
Condition: Condition("[RESPONSE_TIME] >= 500"),
Result: &Result{Duration: 499 * time.Millisecond},
ExpectedSuccess: false,
ExpectedOutput: "[RESPONSE_TIME] (499) >= 500",
},
{
Name: "response-time-using-greater-than-or-equal-to-failure",
Condition: Condition("[RESPONSE_TIME] >= 500"),
Result: &Result{Duration: 499 * time.Millisecond},
ExpectedSuccess: false,
ExpectedOutput: "[RESPONSE_TIME] (499) >= 500",
},
{
Name: "response-time-using-less-than-or-equal-to-equal",
Condition: Condition("[RESPONSE_TIME] <= 500"),
Result: &Result{Duration: 500 * time.Millisecond},
ExpectedSuccess: true,
ExpectedOutput: "[RESPONSE_TIME] <= 500",
},
{
Name: "response-time-using-less-than-or-equal-to-less",
Condition: Condition("[RESPONSE_TIME] <= 500"),
Result: &Result{Duration: 25 * time.Millisecond},
ExpectedSuccess: true,
ExpectedOutput: "[RESPONSE_TIME] <= 500",
},
{
Name: "response-time-using-less-than-or-equal-to-failure",
Condition: Condition("[RESPONSE_TIME] <= 500"),
Result: &Result{Duration: 750 * time.Millisecond},
ExpectedSuccess: false,
ExpectedOutput: "[RESPONSE_TIME] (750) <= 500",
},
{
Name: "body",
Condition: Condition("[BODY] == test"),
Result: &Result{Body: []byte("test")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == test",
},
{
Name: "body-resolved-on-success",
Condition: Condition("[BODY].status == UP"),
Result: &Result{Body: []byte("{\"status\":\"UP\"}")},
ResolveSuccessfulConditions: true,
ExpectedSuccess: true,
ExpectedOutput: "[BODY].status (UP) == UP",
},
{
Name: "body-numerical-equal",
Condition: Condition("[BODY] == 123"),
Result: &Result{Body: []byte("123")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == 123",
},
{
Name: "body-numerical-less-than",
Condition: Condition("[BODY] < 124"),
Result: &Result{Body: []byte("123")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] < 124",
},
{
Name: "body-numerical-greater-than",
Condition: Condition("[BODY] > 122"),
Result: &Result{Body: []byte("123")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] > 122",
},
{
Name: "body-numerical-greater-than-failure",
Condition: Condition("[BODY] > 123"),
Result: &Result{Body: []byte("100")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY] (100) > 123",
},
{
Name: "body-jsonpath",
Condition: Condition("[BODY].status == UP"),
Result: &Result{Body: []byte("{\"status\":\"UP\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].status == UP",
},
{
Name: "body-jsonpath-complex",
Condition: Condition("[BODY].data.name == john"),
Result: &Result{Body: []byte("{\"data\": {\"id\": 1, \"name\": \"john\"}}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data.name == john",
},
{
Name: "body-jsonpath-complex-invalid",
Condition: Condition("[BODY].data.name == john"),
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].data.name (INVALID) == john",
},
{
Name: "body-jsonpath-complex-len-invalid",
Condition: Condition("len([BODY].data.name) == john"),
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
ExpectedSuccess: false,
ExpectedOutput: "len([BODY].data.name) (INVALID) == john",
},
{
Name: "body-jsonpath-double-placeholder",
Condition: Condition("[BODY].user.firstName != [BODY].user.lastName"),
Result: &Result{Body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].user.firstName != [BODY].user.lastName",
},
{
Name: "body-jsonpath-double-placeholder-failure",
Condition: Condition("[BODY].user.firstName == [BODY].user.lastName"),
Result: &Result{Body: []byte("{\"user\": {\"firstName\": \"john\", \"lastName\": \"doe\"}}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].user.firstName (john) == [BODY].user.lastName (doe)",
},
{
Name: "body-jsonpath-when-body-is-array",
Condition: Condition("[BODY][0].id == 1"),
Result: &Result{Body: []byte("[{\"id\": 1}, {\"id\": 2}]")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY][0].id == 1",
},
{
Name: "body-jsonpath-when-body-has-null-parameter",
Condition: Condition("[BODY].data == OK"),
Result: &Result{Body: []byte(`{"data": null}"`)},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].data (INVALID) == OK",
},
{
Name: "body-jsonpath-when-body-has-array-with-null",
Condition: Condition("[BODY].items[0] == OK"),
Result: &Result{Body: []byte(`{"items": [null, null]}"`)},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].items[0] (INVALID) == OK",
},
{
Name: "body-jsonpath-when-body-is-null",
Condition: Condition("[BODY].data == OK"),
Result: &Result{Body: []byte(`null`)},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].data (INVALID) == OK",
},
{
Name: "body-jsonpath-when-body-is-array-but-actual-body-is-not",
Condition: Condition("[BODY][0].name == test"),
Result: &Result{Body: []byte("{\"statusCode\": 500, \"message\": \"Internal Server Error\"}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY][0].name (INVALID) == test",
},
{
Name: "body-jsonpath-complex-int",
Condition: Condition("[BODY].data.id == 1"),
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data.id == 1",
},
{
Name: "body-jsonpath-complex-array-int",
Condition: Condition("[BODY].data[1].id == 2"),
Result: &Result{Body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data[1].id == 2",
},
{
Name: "body-jsonpath-complex-int-using-greater-than",
Condition: Condition("[BODY].data.id > 0"),
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data.id > 0",
},
{
Name: "body-jsonpath-hexadecimal-int-using-greater-than",
Condition: Condition("[BODY].data > 0"),
Result: &Result{Body: []byte("{\"data\": \"0x1\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data > 0",
},
{
Name: "body-jsonpath-hexadecimal-int-using-equal-to-0x1",
Condition: Condition("[BODY].data == 1"),
Result: &Result{Body: []byte("{\"data\": \"0x1\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == 1",
},
{
Name: "body-jsonpath-hexadecimal-int-using-equals",
Condition: Condition("[BODY].data == 0x1"),
Result: &Result{Body: []byte("{\"data\": \"0x1\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == 0x1",
},
{
Name: "body-jsonpath-hexadecimal-int-using-equal-to-0x2",
Condition: Condition("[BODY].data == 2"),
Result: &Result{Body: []byte("{\"data\": \"0x2\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == 2",
},
{
Name: "body-jsonpath-hexadecimal-int-using-equal-to-0xF",
Condition: Condition("[BODY].data == 15"),
Result: &Result{Body: []byte("{\"data\": \"0xF\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == 15",
},
{
Name: "body-jsonpath-hexadecimal-int-using-equal-to-0xC0ff33",
Condition: Condition("[BODY].data == 12648243"),
Result: &Result{Body: []byte("{\"data\": \"0xC0ff33\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == 12648243",
},
{
Name: "body-jsonpath-hexadecimal-int-len",
Condition: Condition("len([BODY].data) == 3"),
Result: &Result{Body: []byte("{\"data\": \"0x1\"}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].data) == 3",
},
{
Name: "body-jsonpath-hexadecimal-int-greater",
Condition: Condition("[BODY].data >= 1"),
Result: &Result{Body: []byte("{\"data\": \"0x01\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data >= 1",
},
{
Name: "body-jsonpath-hexadecimal-int-0x01-len",
Condition: Condition("len([BODY].data) == 4"),
Result: &Result{Body: []byte("{\"data\": \"0x01\"}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].data) == 4",
},
{
Name: "body-jsonpath-octal-int-using-greater-than",
Condition: Condition("[BODY].data > 0"),
Result: &Result{Body: []byte("{\"data\": \"0o1\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data > 0",
},
{
Name: "body-jsonpath-octal-int-using-equal",
Condition: Condition("[BODY].data == 2"),
Result: &Result{Body: []byte("{\"data\": \"0o2\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == 2",
},
{
Name: "body-jsonpath-octal-int-using-equals",
Condition: Condition("[BODY].data == 0o2"),
Result: &Result{Body: []byte("{\"data\": \"0o2\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == 0o2",
},
{
Name: "body-jsonpath-binary-int-using-greater-than",
Condition: Condition("[BODY].data > 0"),
Result: &Result{Body: []byte("{\"data\": \"0b1\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data > 0",
},
{
Name: "body-jsonpath-binary-int-using-equal",
Condition: Condition("[BODY].data == 2"),
Result: &Result{Body: []byte("{\"data\": \"0b0010\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == 2",
},
{
Name: "body-jsonpath-binary-int-using-equals",
Condition: Condition("[BODY].data == 0b10"),
Result: &Result{Body: []byte("{\"data\": \"0b0010\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data == 0b10",
},
{
Name: "body-jsonpath-complex-int-using-greater-than-failure",
Condition: Condition("[BODY].data.id > 5"),
Result: &Result{Body: []byte("{\"data\": {\"id\": 1}}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].data.id (1) > 5",
},
{
Name: "body-jsonpath-float-using-greater-than-issue433", // As of v5.3.1, Gatus will convert a float to an int. We're losing precision, but it's better than just returning 0
Condition: Condition("[BODY].balance > 100"),
Result: &Result{Body: []byte(`{"balance": "123.40000000000005"}`)},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].balance > 100",
},
{
Name: "body-jsonpath-complex-int-using-less-than",
Condition: Condition("[BODY].data.id < 5"),
Result: &Result{Body: []byte("{\"data\": {\"id\": 2}}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].data.id < 5",
},
{
Name: "body-jsonpath-complex-int-using-less-than-failure",
Condition: Condition("[BODY].data.id < 5"),
Result: &Result{Body: []byte("{\"data\": {\"id\": 10}}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].data.id (10) < 5",
},
{
Name: "connected",
Condition: Condition("[CONNECTED] == true"),
Result: &Result{Connected: true},
ExpectedSuccess: true,
ExpectedOutput: "[CONNECTED] == true",
},
{
Name: "connected-failure",
Condition: Condition("[CONNECTED] == true"),
Result: &Result{Connected: false},
ExpectedSuccess: false,
ExpectedOutput: "[CONNECTED] (false) == true",
},
{
Name: "certificate-expiration-not-set",
Condition: Condition("[CERTIFICATE_EXPIRATION] == 0"),
Result: &Result{},
ExpectedSuccess: true,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] == 0",
},
{
Name: "certificate-expiration-greater-than-numerical",
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
Result: &Result{CertificateExpiration: time.Hour * 24 * 60},
ExpectedSuccess: true,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] > 2419200000",
},
{
Name: "certificate-expiration-greater-than-numerical-failure",
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (336h) > 2419200000",
},
{
Name: "certificate-expiration-greater-than-duration",
Condition: Condition("[CERTIFICATE_EXPIRATION] > 12h"),
Result: &Result{CertificateExpiration: 24 * time.Hour},
ExpectedSuccess: true,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] > 12h",
},
{
Name: "certificate-expiration-greater-than-duration",
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
Result: &Result{CertificateExpiration: 24 * time.Hour},
ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (24h) > 48h",
},
{
Name: "no-placeholders",
Condition: Condition("1 == 2"),
Result: &Result{},
ExpectedSuccess: false,
ExpectedOutput: "1 == 2",
},
///////////////
// Functions //
///////////////
// len
{
Name: "len-body-jsonpath-complex",
Condition: Condition("len([BODY].data.name) == 4"),
Result: &Result{Body: []byte("{\"data\": {\"name\": \"john\"}}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].data.name) == 4",
},
{
Name: "len-body-array",
Condition: Condition("len([BODY]) == 3"),
Result: &Result{Body: []byte("[{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY]) == 3",
},
{
Name: "len-body-keyed-array",
Condition: Condition("len([BODY].data) == 3"),
Result: &Result{Body: []byte("{\"data\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].data) == 3",
},
{
Name: "len-body-array-invalid",
Condition: Condition("len([BODY].data) == 8"),
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: false,
ExpectedOutput: "len([BODY].data) (INVALID) == 8",
},
{
Name: "len-body-string",
Condition: Condition("len([BODY]) == 8"),
Result: &Result{Body: []byte("john.doe")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY]) == 8",
},
{
Name: "len-body-keyed-string",
Condition: Condition("len([BODY].name) == 8"),
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].name) == 8",
},
{
Name: "len-body-keyed-int",
Condition: Condition("len([BODY].age) == 2"),
Result: &Result{Body: []byte(`{"age":18}`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].age) == 2",
},
{
Name: "len-body-keyed-bool",
Condition: Condition("len([BODY].adult) == 4"),
Result: &Result{Body: []byte(`{"adult":true}`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY].adult) == 4",
},
{
Name: "len-body-object-inside-array",
Condition: Condition("len([BODY][0]) == 23"),
Result: &Result{Body: []byte(`[{"age":18,"adult":true}]`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY][0]) == 23",
},
{
Name: "len-body-object-keyed-int-inside-array",
Condition: Condition("len([BODY][0].age) == 2"),
Result: &Result{Body: []byte(`[{"age":18,"adult":true}]`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY][0].age) == 2",
},
{
Name: "len-body-keyed-bool-inside-array",
Condition: Condition("len([BODY][0].adult) == 4"),
Result: &Result{Body: []byte(`[{"age":18,"adult":true}]`)},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY][0].adult) == 4",
},
{
Name: "len-body-object",
Condition: Condition("len([BODY]) == 20"),
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "len([BODY]) == 20",
},
// pat
{
Name: "pat-body-1",
Condition: Condition("[BODY] == pat(*john*)"),
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == pat(*john*)",
},
{
Name: "pat-body-2",
Condition: Condition("[BODY].name == pat(john*)"),
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: true,
ExpectedOutput: "[BODY].name == pat(john*)",
},
{
Name: "pat-body-failure",
Condition: Condition("[BODY].name == pat(bob*)"),
Result: &Result{Body: []byte("{\"name\": \"john.doe\"}")},
ExpectedSuccess: false,
ExpectedOutput: "[BODY].name (john.doe) == pat(bob*)",
},
{
Name: "pat-body-html",
Condition: Condition("[BODY] == pat(*john.doe
*)"),
Result: &Result{Body: []byte(`john.doe
`)},
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == pat(*john.doe
*)",
},
{
Name: "pat-body-html-failure",
Condition: Condition("[BODY] == pat(*john.doe
*)"),
Result: &Result{Body: []byte(`jane.doe
`)},
ExpectedSuccess: false,
ExpectedOutput: "[BODY] (john.doe*)",
},
{
Name: "pat-body-html-failure-alt",
Condition: Condition("pat(*john.doe
*) == [BODY]"),
Result: &Result{Body: []byte(`jane.doe
`)},
ExpectedSuccess: false,
ExpectedOutput: "pat(*john.doe
*) == [BODY] ( '")
// ErrInvalidEndpointIntervalForDomainExpirationPlaceholder is the error with which Gatus will panic if an endpoint
// has both an interval smaller than 5 minutes and a condition with DomainExpirationPlaceholder.
// This is because the free whois service we are using should not be abused, especially considering the fact that
// the data takes a while to be updated.
ErrInvalidEndpointIntervalForDomainExpirationPlaceholder = errors.New("the minimum interval for an endpoint with a condition using the " + DomainExpirationPlaceholder + " placeholder is 300s (5m)")
)
// Endpoint is the configuration of a service to be monitored
type Endpoint struct {
// Enabled defines whether to enable the monitoring of the endpoint
Enabled *bool `yaml:"enabled,omitempty"`
// Name of the endpoint. Can be anything.
Name string `yaml:"name"`
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
Group string `yaml:"group,omitempty"`
// URL to send the request to
URL string `yaml:"url"`
// Method of the request made to the url of the endpoint
Method string `yaml:"method,omitempty"`
// Body of the request
Body string `yaml:"body,omitempty"`
// GraphQL is whether to wrap the body in a query param ({"query":"$body"})
GraphQL bool `yaml:"graphql,omitempty"`
// Headers of the request
Headers map[string]string `yaml:"headers,omitempty"`
// ExtraLabels are key-value pairs that can be used to metric the endpoint
ExtraLabels map[string]string `yaml:"extra-labels,omitempty"`
// Interval is the duration to wait between every status check
Interval time.Duration `yaml:"interval,omitempty"`
// Conditions used to determine the health of the endpoint
Conditions []Condition `yaml:"conditions"`
// Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
// DNSConfig is the configuration for DNS monitoring
DNSConfig *dns.Config `yaml:"dns,omitempty"`
// SSH is the configuration for SSH monitoring
SSHConfig *sshconfig.Config `yaml:"ssh,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the endpoint's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// UIConfig is the configuration for the UI
UIConfig *ui.Config `yaml:"ui,omitempty"`
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
NumberOfFailuresInARow int `yaml:"-"`
// NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int `yaml:"-"`
// LastReminderSent is the time at which the last reminder was sent for this endpoint.
LastReminderSent time.Time `yaml:"-"`
///////////////////////
// SUITE-ONLY FIELDS //
///////////////////////
// Store is a map of values to extract from the result and store in the suite context
// This field is only used when the endpoint is part of a suite
Store map[string]string `yaml:"store,omitempty"`
// AlwaysRun defines whether to execute this endpoint even if previous endpoints in the suite failed
// This field is only used when the endpoint is part of a suite
AlwaysRun bool `yaml:"always-run,omitempty"`
}
// IsEnabled returns whether the endpoint is enabled or not
func (e *Endpoint) IsEnabled() bool {
if e.Enabled == nil {
return true
}
return *e.Enabled
}
// Type returns the endpoint type
func (e *Endpoint) Type() Type {
switch {
case e.DNSConfig != nil:
return TypeDNS
case strings.HasPrefix(e.URL, "tcp://"):
return TypeTCP
case strings.HasPrefix(e.URL, "sctp://"):
return TypeSCTP
case strings.HasPrefix(e.URL, "udp://"):
return TypeUDP
case strings.HasPrefix(e.URL, "icmp://"):
return TypeICMP
case strings.HasPrefix(e.URL, "starttls://"):
return TypeSTARTTLS
case strings.HasPrefix(e.URL, "tls://"):
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://"):
return TypeSSH
default:
return TypeUNKNOWN
}
}
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one
func (e *Endpoint) ValidateAndSetDefaults() error {
if err := validateEndpointNameGroupAndAlerts(e.Name, e.Group, e.Alerts); err != nil {
return err
}
if len(e.URL) == 0 {
return ErrEndpointWithNoURL
}
if e.ClientConfig == nil {
e.ClientConfig = client.GetDefaultConfig()
} else {
if err := e.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if e.UIConfig == nil {
e.UIConfig = ui.GetDefaultConfig()
} else {
if err := e.UIConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if e.Interval == 0 {
e.Interval = 1 * time.Minute
}
if len(e.Method) == 0 {
e.Method = http.MethodGet
}
if len(e.Headers) == 0 {
e.Headers = make(map[string]string)
}
// Automatically add user agent header if there isn't one specified in the endpoint configuration
if !hasHeader(e.Headers, UserAgentHeader) {
e.Headers[UserAgentHeader] = GatusUserAgent
}
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
// and endpoint.GraphQL is set to true
if !hasHeader(e.Headers, ContentTypeHeader) && e.GraphQL {
e.Headers[ContentTypeHeader] = "application/json"
}
if len(e.Conditions) == 0 {
return ErrEndpointWithNoCondition
}
for _, c := range e.Conditions {
if e.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() {
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
}
if err := c.Validate(); err != nil {
return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err)
}
}
if e.DNSConfig != nil {
return e.DNSConfig.ValidateAndSetDefault()
}
if e.SSHConfig != nil {
return e.SSHConfig.Validate()
}
if e.Type() == TypeUNKNOWN {
return ErrUnknownEndpointType
}
for _, maintenanceWindow := range e.MaintenanceWindows {
if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {
return err
}
}
// Make sure that the request can be created
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody())))
if err != nil {
return err
}
return nil
}
// DisplayName returns an identifier made up of the Name and, if not empty, the Group.
func (e *Endpoint) DisplayName() string {
if len(e.Group) > 0 {
return e.Group + "/" + e.Name
}
return e.Name
}
// Key returns the unique key for the Endpoint
func (e *Endpoint) Key() string {
return key.ConvertGroupAndNameToKey(e.Group, e.Name)
}
// Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors
// on configuration reload.
// More context on https://github.com/TwiN/gatus/issues/536
func (e *Endpoint) Close() {
if e.Type() == TypeHTTP {
client.GetHTTPClient(e.ClientConfig).CloseIdleConnections()
}
}
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
func (e *Endpoint) EvaluateHealth() *Result {
return e.EvaluateHealthWithContext(nil)
}
// EvaluateHealthWithContext sends a request to the endpoint's URL with context support and evaluates the conditions
func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
result := &Result{Success: true, Errors: []string{}}
// Preprocess the endpoint with context if provided
processedEndpoint := e
if context != nil {
processedEndpoint = e.preprocessWithContext(result, context)
}
// Parse or extract hostname from URL
if processedEndpoint.DNSConfig != nil {
result.Hostname = strings.TrimSuffix(processedEndpoint.URL, ":53")
} else if processedEndpoint.Type() == TypeICMP {
// To handle IPv6 addresses, we need to handle the hostname differently here. This is to avoid, for instance,
// "1111:2222:3333::4444" being displayed as "1111:2222:3333:" because :4444 would be interpreted as a port.
result.Hostname = strings.TrimPrefix(processedEndpoint.URL, "icmp://")
} else {
urlObject, err := url.Parse(processedEndpoint.URL)
if err != nil {
result.AddError(err.Error())
} else {
result.Hostname = urlObject.Hostname()
result.port = urlObject.Port()
}
}
// Retrieve IP if necessary
if processedEndpoint.needsToRetrieveIP() {
processedEndpoint.getIP(result)
}
// Retrieve domain expiration if necessary
if processedEndpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
var err error
if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil {
result.AddError(err.Error())
}
}
// Call the endpoint (if there's no errors)
if len(result.Errors) == 0 {
processedEndpoint.call(result)
} else {
result.Success = false
}
// Evaluate the conditions
for _, condition := range processedEndpoint.Conditions {
success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, processedEndpoint.UIConfig.ResolveSuccessfulConditions, context)
if !success {
result.Success = false
}
}
result.Timestamp = time.Now()
// Clean up parameters that we don't need to keep in the results
if processedEndpoint.UIConfig.HideURL {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, processedEndpoint.URL, "")
}
}
if processedEndpoint.UIConfig.HideHostname {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "")
}
result.Hostname = "" // remove it from the result so it doesn't get exposed
}
if processedEndpoint.UIConfig.HidePort && len(result.port) > 0 {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.port, "")
}
result.port = ""
}
if processedEndpoint.UIConfig.HideErrors {
result.Errors = nil
}
if processedEndpoint.UIConfig.HideConditions {
result.ConditionResults = nil
}
return result
}
// preprocessWithContext creates a copy of the endpoint with context placeholders replaced
func (e *Endpoint) preprocessWithContext(result *Result, context *gontext.Gontext) *Endpoint {
// Create a deep copy of the endpoint
processed := &Endpoint{}
*processed = *e
var err error
// Replace context placeholders in URL
if processed.URL, err = replaceContextPlaceholders(e.URL, context); err != nil {
result.AddError(err.Error())
}
// Replace context placeholders in Body
if processed.Body, err = replaceContextPlaceholders(e.Body, context); err != nil {
result.AddError(err.Error())
}
// Replace context placeholders in Headers
if e.Headers != nil {
processed.Headers = make(map[string]string)
for k, v := range e.Headers {
if processed.Headers[k], err = replaceContextPlaceholders(v, context); err != nil {
result.AddError(err.Error())
}
}
}
return processed
}
// replaceContextPlaceholders replaces [CONTEXT].path placeholders with actual values
func replaceContextPlaceholders(input string, ctx *gontext.Gontext) (string, error) {
if ctx == nil {
return input, nil
}
var contextErrors []string
contextRegex := regexp.MustCompile(`\[CONTEXT\]\.[\w\.\-]+`)
result := contextRegex.ReplaceAllStringFunc(input, func(match string) string {
// Extract the path after [CONTEXT].
path := strings.TrimPrefix(match, "[CONTEXT].")
value, err := ctx.Get(path)
if err != nil {
contextErrors = append(contextErrors, fmt.Sprintf("path '%s' not found", path))
return match // Keep placeholder for error reporting
}
return fmt.Sprintf("%v", value)
})
if len(contextErrors) > 0 {
return result, fmt.Errorf("context placeholder resolution failed: %s", strings.Join(contextErrors, ", "))
}
return result, nil
}
func (e *Endpoint) getParsedBody() string {
body := e.Body
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", e.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", e.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", e.URL)
randRegex, err := regexp.Compile(`\[RANDOM_STRING_\d+\]`)
if err == nil {
body = randRegex.ReplaceAllStringFunc(body, func(match string) string {
n, _ := strconv.Atoi(match[15 : len(match)-1])
if n > 8192 {
n = 8192 // Limit the length of the random string to 8192 bytes to avoid excessive memory usage
}
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
return string(b)
})
}
return body
}
func (e *Endpoint) getIP(result *Result) {
if ips, err := net.LookupIP(result.Hostname); err != nil {
result.AddError(err.Error())
return
} else {
result.IP = ips[0].String()
}
}
func (e *Endpoint) call(result *Result) {
var request *http.Request
var response *http.Response
var err error
var certificate *x509.Certificate
endpointType := e.Type()
if endpointType == TypeHTTP {
request = e.buildHTTPRequest()
}
startTime := time.Now()
if endpointType == TypeDNS {
result.Connected, result.DNSRCode, result.Body, err = client.QueryDNS(e.DNSConfig.QueryType, e.DNSConfig.QueryName, e.URL)
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeSTARTTLS || endpointType == TypeTLS {
if endpointType == TypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig)
} else {
result.Connected, result.Body, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.getParsedBody(), e.ClientConfig)
}
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if endpointType == TypeTCP {
result.Connected, result.Body = client.CanCreateNetworkConnection("tcp", strings.TrimPrefix(e.URL, "tcp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeUDP {
result.Connected, result.Body = client.CanCreateNetworkConnection("udp", strings.TrimPrefix(e.URL, "udp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeSCTP {
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig)
} else if endpointType == TypeWS {
wsHeaders := map[string]string{}
if e.Headers != nil {
maps.Copy(wsHeaders, e.Headers)
}
if !hasHeader(wsHeaders, UserAgentHeader) {
wsHeaders[UserAgentHeader] = GatusUserAgent
}
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeSSH {
// If there's no username, password or private key specified, attempt to validate just the SSH banner
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Success = result.Connected
result.Duration = time.Since(startTime)
return
}
var cli *ssh.Client
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
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)
if err != nil {
result.AddError(err.Error())
return
}
defer response.Body.Close()
if response.TLS != nil && len(response.TLS.PeerCertificates) > 0 {
certificate = response.TLS.PeerCertificates[0]
result.CertificateExpiration = time.Until(certificate.NotAfter)
}
result.HTTPStatus = response.StatusCode
result.Connected = response.StatusCode > 0
// Only read the Body if there's a condition that uses the BodyPlaceholder
if e.needsToReadBody() {
result.Body, err = io.ReadAll(response.Body)
if err != nil {
result.AddError("error reading response body:" + err.Error())
}
}
}
}
func (e *Endpoint) buildHTTPRequest() *http.Request {
var bodyBuffer *bytes.Buffer
if e.GraphQL {
graphQlBody := map[string]string{
"query": e.getParsedBody(),
}
body, _ := json.Marshal(graphQlBody)
bodyBuffer = bytes.NewBuffer(body)
} else {
bodyBuffer = bytes.NewBuffer([]byte(e.getParsedBody()))
}
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
for k, v := range e.Headers {
request.Header.Set(k, v)
if strings.EqualFold(k, HostHeader) {
request.Host = v
}
}
return request
}
// 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
}
// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed
func (e *Endpoint) needsToRetrieveDomainExpiration() bool {
for _, condition := range e.Conditions {
if condition.hasDomainExpirationPlaceholder() {
return true
}
}
return false
}
// needsToRetrieveIP checks if there's any condition that requires an IP lookup
func (e *Endpoint) needsToRetrieveIP() bool {
for _, condition := range e.Conditions {
if condition.hasIPPlaceholder() {
return true
}
}
return false
}
// hasHeader checks if a header exists in the map using a case-insensitive lookup
func hasHeader(headers map[string]string, name string) bool {
for k := range headers {
if strings.EqualFold(k, name) {
return true
}
}
return false
}
================================================
FILE: config/endpoint/endpoint_test.go
================================================
package endpoint
import (
"bytes"
"crypto/tls"
"crypto/x509"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/config/gontext"
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/test"
)
func TestHasHeader(t *testing.T) {
scenarios := []struct {
name string
headers map[string]string
lookup string
expected bool
}{
{name: "exact-match", headers: map[string]string{"User-Agent": "test"}, lookup: "User-Agent", expected: true},
{name: "lowercase-lookup", headers: map[string]string{"User-Agent": "test"}, lookup: "user-agent", expected: true},
{name: "uppercase-lookup", headers: map[string]string{"user-agent": "test"}, lookup: "USER-AGENT", expected: true},
{name: "mixed-case", headers: map[string]string{"UsEr-AgEnT": "test"}, lookup: "uSeR-aGeNt", expected: true},
{name: "not-found", headers: map[string]string{"Content-Type": "test"}, lookup: "User-Agent", expected: false},
{name: "empty-headers", headers: map[string]string{}, lookup: "User-Agent", expected: false},
{name: "nil-headers", headers: nil, lookup: "User-Agent", expected: false},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if result := hasHeader(scenario.headers, scenario.lookup); result != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, result)
}
})
}
}
func TestEndpoint(t *testing.T) {
defer client.InjectHTTPClient(nil)
scenarios := []struct {
Name string
Endpoint Endpoint
ExpectedResult *Result
MockRoundTripper test.MockRoundTripper
}{
{
Name: "success",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP", "[CERTIFICATE_EXPIRATION] > 24h"},
},
ExpectedResult: &Result{
Success: true,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[BODY].status == UP", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 24h", Success: true},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`)),
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(9999 * time.Hour)}}},
}
}),
},
{
Name: "failed-body-condition",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[BODY].status (DOWN) == UP", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "DOWN"}`))}
}),
},
{
Name: "failed-status-condition",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200"},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] (502) == 200", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
},
{
Name: "failed-status-condition-with-hidden-conditions",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{HideConditions: true},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{}, // Because UIConfig.HideConditions is true, the condition results should not be shown.
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
},
{
Name: "condition-with-failed-certificate-expiration",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CERTIFICATE_EXPIRATION] > 100h"},
UIConfig: &ui.Config{DontResolveFailedConditions: true},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
// Because UIConfig.DontResolveFailedConditions is true, the values in the condition should not be resolved
{Condition: "[CERTIFICATE_EXPIRATION] > 100h", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(5 * time.Hour)}}},
}
}),
},
{
Name: "domain-expiration",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[DOMAIN_EXPIRATION] > 100h"},
Interval: 5 * time.Minute,
},
ExpectedResult: &Result{
Success: true,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[DOMAIN_EXPIRATION] > 100h", Success: true},
},
DomainExpiration: 999999 * time.Hour, // Note that this test only checks if it's non-zero.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "endpoint-that-will-time-out-and-hidden-hostname",
Endpoint: Endpoint{
Name: "endpoint-that-will-time-out",
URL: "https://twin.sh:9999/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideHostname: true, HidePort: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
ExpectedResult: &Result{
Success: false,
Connected: false,
Hostname: "", // Because Endpoint.UIConfig.HideHostname is true, this should be empty.
ConditionResults: []*ConditionResult{
{Condition: "[CONNECTED] (false) == true", Success: false},
},
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
DomainExpiration: 0,
// Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by .
Errors: []string{`Get "https://:/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
},
MockRoundTripper: nil,
},
{
Name: "endpoint-that-will-time-out-and-hidden-url",
Endpoint: Endpoint{
Name: "endpoint-that-will-time-out",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideURL: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
ExpectedResult: &Result{
Success: false,
Connected: false,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[CONNECTED] (false) == true", Success: false},
},
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
DomainExpiration: 0,
// Because Endpoint.UIConfig.HideURL is true, the URL should be replaced by .
Errors: []string{`Get "": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
},
MockRoundTripper: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.MockRoundTripper != nil {
mockClient := &http.Client{Transport: scenario.MockRoundTripper}
if scenario.Endpoint.ClientConfig != nil && scenario.Endpoint.ClientConfig.Timeout > 0 {
mockClient.Timeout = scenario.Endpoint.ClientConfig.Timeout
}
client.InjectHTTPClient(mockClient)
} else {
client.InjectHTTPClient(nil)
}
err := scenario.Endpoint.ValidateAndSetDefaults()
if err != nil {
t.Error("did not expect an error, got", err)
}
result := scenario.Endpoint.EvaluateHealth()
if result.Success != scenario.ExpectedResult.Success {
t.Errorf("Expected success to be %v, got %v", scenario.ExpectedResult.Success, result.Success)
}
if result.Connected != scenario.ExpectedResult.Connected {
t.Errorf("Expected connected to be %v, got %v", scenario.ExpectedResult.Connected, result.Connected)
}
if result.Hostname != scenario.ExpectedResult.Hostname {
t.Errorf("Expected hostname to be %v, got %v", scenario.ExpectedResult.Hostname, result.Hostname)
}
if len(result.ConditionResults) != len(scenario.ExpectedResult.ConditionResults) {
t.Errorf("Expected %v condition results, got %v", len(scenario.ExpectedResult.ConditionResults), len(result.ConditionResults))
} else {
for i, conditionResult := range result.ConditionResults {
if conditionResult.Condition != scenario.ExpectedResult.ConditionResults[i].Condition {
t.Errorf("Expected condition to be %v, got %v", scenario.ExpectedResult.ConditionResults[i].Condition, conditionResult.Condition)
}
if conditionResult.Success != scenario.ExpectedResult.ConditionResults[i].Success {
t.Errorf("Expected success of condition '%s' to be %v, got %v", conditionResult.Condition, scenario.ExpectedResult.ConditionResults[i].Success, conditionResult.Success)
}
}
}
if len(result.Errors) != len(scenario.ExpectedResult.Errors) {
t.Errorf("Expected %v errors, got %v", len(scenario.ExpectedResult.Errors), len(result.Errors))
} else {
for i, err := range result.Errors {
if err != scenario.ExpectedResult.Errors[i] {
t.Errorf("Expected error to be %v, got %v", scenario.ExpectedResult.Errors[i], err)
}
}
}
if result.DomainExpiration != scenario.ExpectedResult.DomainExpiration {
// Note that DomainExpiration is only resolved if there's a condition with the DomainExpirationPlaceholder in it.
// In other words, if there's no condition with [DOMAIN_EXPIRATION] in it, the DomainExpiration field will be 0.
// Because this is a live call, mocking it would be too much of a pain, so we're just going to check if
// the actual value is non-zero when the expected result is non-zero.
if scenario.ExpectedResult.DomainExpiration.Hours() > 0 && !(result.DomainExpiration.Hours() > 0) {
t.Errorf("Expected domain expiration to be non-zero, got %v", result.DomainExpiration)
}
}
})
}
}
func TestEndpoint_ResolveSuccessfulConditions(t *testing.T) {
defer client.InjectHTTPClient(nil)
endpoint := Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[BODY].status == UP"},
UIConfig: &ui.Config{ResolveSuccessfulConditions: true},
}
mockResponse := test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status":"UP"}`))}
})
client.InjectHTTPClient(&http.Client{Transport: mockResponse})
if err := endpoint.ValidateAndSetDefaults(); err != nil {
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
}
result := endpoint.EvaluateHealth()
if len(result.ConditionResults) != 1 {
t.Fatalf("expected 1 condition result, got %d", len(result.ConditionResults))
}
expectedCondition := "[BODY].status (UP) == UP"
if result.ConditionResults[0].Condition != expectedCondition {
t.Errorf("expected condition to be '%s', got '%s'", expectedCondition, result.ConditionResults[0].Condition)
}
}
func TestEndpoint_IsEnabled(t *testing.T) {
if !(&Endpoint{Enabled: nil}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
}
if value := false; (&Endpoint{Enabled: &value}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned false, because Enabled was set to false")
}
if value := true; !(&Endpoint{Enabled: &value}).IsEnabled() {
t.Error("Endpoint.IsEnabled() should've returned true, because Enabled was set to true")
}
}
func TestEndpoint_Type(t *testing.T) {
type args struct {
URL string
DNS *dns.Config
SSH *ssh.Config
}
tests := []struct {
args args
want Type
}{
{
args: args{
URL: "8.8.8.8",
DNS: &dns.Config{
QueryType: "A",
QueryName: "example.com",
},
},
want: TypeDNS,
},
{
args: args{
URL: "tcp://127.0.0.1:6379",
},
want: TypeTCP,
},
{
args: args{
URL: "icmp://example.com",
},
want: TypeICMP,
},
{
args: args{
URL: "sctp://example.com",
},
want: TypeSCTP,
},
{
args: args{
URL: "udp://example.com",
},
want: TypeUDP,
},
{
args: args{
URL: "starttls://smtp.gmail.com:587",
},
want: TypeSTARTTLS,
},
{
args: args{
URL: "tls://example.com:443",
},
want: TypeTLS,
},
{
args: args{
URL: "https://twin.sh/health",
},
want: TypeHTTP,
},
{
args: args{
URL: "wss://example.com/",
},
want: TypeWS,
},
{
args: args{
URL: "ws://example.com/",
},
want: TypeWS,
},
{
args: args{
URL: "ssh://example.com:22",
SSH: &ssh.Config{
Username: "root",
Password: "password",
},
},
want: TypeSSH,
},
{
args: args{
URL: "invalid://example.org",
},
want: TypeUNKNOWN,
},
{
args: args{
URL: "no-scheme",
},
want: TypeUNKNOWN,
},
}
for _, tt := range tests {
t.Run(string(tt.want), func(t *testing.T) {
endpoint := Endpoint{
URL: tt.args.URL,
DNSConfig: tt.args.DNS,
}
if got := endpoint.Type(); got != tt.want {
t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want)
}
})
}
}
func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{Condition("[STATUS] == 200")},
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
MaintenanceWindows: []*maintenance.Config{{Start: "03:50", Duration: 4 * time.Hour}},
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
t.Errorf("Expected no error, got %v", err)
}
if endpoint.ClientConfig == nil {
t.Error("client configuration should've been set to the default configuration")
} else {
if endpoint.ClientConfig.Insecure != client.GetDefaultConfig().Insecure {
t.Errorf("Default client configuration should've set Insecure to %v, got %v", client.GetDefaultConfig().Insecure, endpoint.ClientConfig.Insecure)
}
if endpoint.ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {
t.Errorf("Default client configuration should've set IgnoreRedirect to %v, got %v", client.GetDefaultConfig().IgnoreRedirect, endpoint.ClientConfig.IgnoreRedirect)
}
if endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Errorf("Default client configuration should've set Timeout to %v, got %v", client.GetDefaultConfig().Timeout, endpoint.ClientConfig.Timeout)
}
}
if endpoint.Method != "GET" {
t.Error("Endpoint method should've defaulted to GET")
}
if endpoint.Interval != time.Minute {
t.Error("Endpoint interval should've defaulted to 1 minute")
}
if endpoint.Headers == nil {
t.Error("Endpoint headers should've defaulted to an empty map")
}
if len(endpoint.Alerts) != 1 {
t.Error("Endpoint should've had 1 alert")
}
if !endpoint.Alerts[0].IsEnabled() {
t.Error("Endpoint alert should've defaulted to true")
}
if endpoint.Alerts[0].SuccessThreshold != 2 {
t.Error("Endpoint alert should've defaulted to a success threshold of 2")
}
if endpoint.Alerts[0].FailureThreshold != 3 {
t.Error("Endpoint alert should've defaulted to a failure threshold of 3")
}
if len(endpoint.MaintenanceWindows) != 1 {
t.Error("Endpoint should've had 1 maintenance window")
}
if !endpoint.MaintenanceWindows[0].IsEnabled() {
t.Error("Endpoint maintenance should've defaulted to true")
}
if endpoint.MaintenanceWindows[0].Timezone != "UTC" {
t.Error("Endpoint maintenance should've defaulted to UTC")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithInvalidCondition(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-condition",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] invalid 200"},
}
if err := endpoint.ValidateAndSetDefaults(); err == nil {
t.Error("endpoint validation should've returned an error, but didn't")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{Condition("[STATUS] == 200")},
ClientConfig: &client.Config{
Insecure: true,
IgnoreRedirect: true,
Timeout: 0,
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
if endpoint.ClientConfig == nil {
t.Error("client configuration should've been set to the default configuration")
} else {
if !endpoint.ClientConfig.Insecure {
t.Error("endpoint.ClientConfig.Insecure should've been set to true")
}
if !endpoint.ClientConfig.IgnoreRedirect {
t.Error("endpoint.ClientConfig.IgnoreRedirect should've been set to true")
}
if endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {
t.Error("endpoint.ClientConfig.Timeout should've been set to 10s, because the timeout value entered is not set or invalid")
}
}
}
func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
endpoint := &Endpoint{
Name: "dns-test",
URL: "https://example.com",
DNSConfig: &dns.Config{
QueryType: "A",
QueryName: "example.com",
},
Conditions: []Condition{Condition("[DNS_RCODE] == NOERROR")},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Error("did not expect an error, got", err)
}
if endpoint.DNSConfig.QueryName != "example.com." {
t.Error("Endpoint.dns.query-name should be formatted with . suffix")
}
}
func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
scenarios := []struct {
name string
username string
password string
privateKey string
expectedErr error
}{
{
name: "fail when has no user but has password",
username: "",
password: "password",
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
},
{
name: "fail when has no user but has private key",
username: "",
privateKey: "-----BEGIN",
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
},
{
name: "fail when has no password or private key",
username: "username",
password: "",
privateKey: "",
expectedErr: ssh.ErrEndpointWithoutSSHAuth,
},
{
name: "success when username and password are set",
username: "username",
password: "password",
expectedErr: nil,
},
{
name: "success when username and private key are set",
username: "username",
privateKey: "-----BEGIN",
expectedErr: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
endpoint := &Endpoint{
Name: "ssh-test",
URL: "https://example.com",
SSHConfig: &ssh.Config{
Username: scenario.username,
Password: scenario.password,
PrivateKey: scenario.privateKey,
},
Conditions: []Condition{Condition("[STATUS] == 0")},
}
err := endpoint.ValidateAndSetDefaults()
if !errors.Is(err, scenario.expectedErr) {
t.Errorf("expected error %v, got %v", scenario.expectedErr, err)
}
})
}
}
func TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) {
scenarios := []struct {
endpoint *Endpoint
expectedErr error
}{
{
endpoint: &Endpoint{
Name: "",
URL: "https://example.com",
Conditions: []Condition{Condition("[STATUS] == 200")},
},
expectedErr: ErrEndpointWithNoName,
},
{
endpoint: &Endpoint{
Name: "endpoint-with-no-url",
URL: "",
Conditions: []Condition{Condition("[STATUS] == 200")},
},
expectedErr: ErrEndpointWithNoURL,
},
{
endpoint: &Endpoint{
Name: "endpoint-with-no-conditions",
URL: "https://example.com",
Conditions: nil,
},
expectedErr: ErrEndpointWithNoCondition,
},
{
endpoint: &Endpoint{
Name: "domain-expiration-with-bad-interval",
URL: "https://example.com",
Interval: time.Minute,
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
},
expectedErr: ErrInvalidEndpointIntervalForDomainExpirationPlaceholder,
},
{
endpoint: &Endpoint{
Name: "domain-expiration-with-good-interval",
URL: "https://example.com",
Interval: 5 * time.Minute,
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
},
expectedErr: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.endpoint.Name, func(t *testing.T) {
if err := scenario.endpoint.ValidateAndSetDefaults(); err != scenario.expectedErr {
t.Errorf("Expected error %v, got %v", scenario.expectedErr, err)
}
})
}
}
func TestEndpoint_buildHTTPRequest(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{condition},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest()
if request.Method != "GET" {
t.Error("request.Method should've been GET, but was", request.Method)
}
if request.Host != "twin.sh" {
t.Error("request.Host should've been twin.sh, but was", request.Host)
}
if userAgent := request.Header.Get("User-Agent"); userAgent != GatusUserAgent {
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", GatusUserAgent, userAgent)
}
}
func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{condition},
Headers: map[string]string{
"User-Agent": "Test/2.0",
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest()
if request.Method != "GET" {
t.Error("request.Method should've been GET, but was", request.Method)
}
if request.Host != "twin.sh" {
t.Error("request.Host should've been twin.sh, but was", request.Host)
}
if userAgent := request.Header.Get("User-Agent"); userAgent != "Test/2.0" {
t.Errorf("request.Header.Get(User-Agent) should've been %s, but was %s", "Test/2.0", userAgent)
}
}
func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Method: "POST",
Conditions: []Condition{condition},
Headers: map[string]string{
"Host": "example.com",
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest()
if request.Method != "POST" {
t.Error("request.Method should've been POST, but was", request.Method)
}
if request.Host != "example.com" {
t.Error("request.Host should've been example.com, but was", request.Host)
}
}
func TestEndpoint_buildHTTPRequestWithLowercaseUserAgent(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{condition},
Headers: map[string]string{
"user-agent": "CustomAgent/1.0",
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
if _, exists := endpoint.Headers[UserAgentHeader]; exists {
t.Error("User-Agent header should not have been added since user-agent was already specified")
}
request := endpoint.buildHTTPRequest()
if userAgent := request.Header.Get("User-Agent"); userAgent != "CustomAgent/1.0" {
t.Errorf("request.Header.Get(User-Agent) should've been CustomAgent/1.0, but was %s", userAgent)
}
}
func TestEndpoint_buildHTTPRequestWithLowercaseContentType(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-graphql",
URL: "https://twin.sh/graphql",
Method: "POST",
Conditions: []Condition{condition},
GraphQL: true,
Headers: map[string]string{
"content-type": "application/graphql",
},
Body: `{ users { id } }`,
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
if _, exists := endpoint.Headers[ContentTypeHeader]; exists {
t.Error("Content-Type header should not have been added since content-type was already specified")
}
request := endpoint.buildHTTPRequest()
if contentType := request.Header.Get("Content-Type"); contentType != "application/graphql" {
t.Errorf("request.Header.Get(Content-Type) should've been application/graphql, but was %s", contentType)
}
}
func TestEndpoint_buildHTTPRequestWithLowercaseHostHeader(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Method: "POST",
Conditions: []Condition{condition},
Headers: map[string]string{
"host": "example.com",
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest()
if request.Host != "example.com" {
t.Error("request.Host should've been example.com, but was", request.Host)
}
}
func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-graphql",
URL: "https://twin.sh/graphql",
Method: "POST",
Conditions: []Condition{condition},
GraphQL: true,
Body: `{
users(gender: "female") {
id
name
gender
avatar
}
}`,
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest()
if request.Method != "POST" {
t.Error("request.Method should've been POST, but was", request.Method)
}
if contentType := request.Header.Get(ContentTypeHeader); contentType != "application/json" {
t.Error("request.Header.Content-Type should've been application/json, but was", contentType)
}
body, _ := io.ReadAll(request.Body)
if !strings.HasPrefix(string(body), "{\"query\":") {
t.Error("request.body should've started with '{\"query\":', but it didn't:", string(body))
}
}
func TestIntegrationEvaluateHealth(t *testing.T) {
condition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{condition, bodyCondition},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a success", condition)
}
if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true")
}
if !result.Success {
t.Error("Because all conditions passed, this should have been a success")
}
if result.Hostname != "twin.sh" {
t.Error("result.Hostname should've been twin.sh, but was", result.Hostname)
}
}
func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-url",
URL: "https://httpstat.us/200?sleep=100",
Conditions: []Condition{Condition("[STATUS] == 200")},
ClientConfig: &client.Config{
Timeout: 1 * time.Millisecond,
},
UIConfig: &ui.Config{
HideURL: true,
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
result := endpoint.EvaluateHealth()
if result.Success {
t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
if !strings.Contains(result.Errors[0], "") || strings.Contains(result.Errors[0], endpoint.URL) {
t.Error("result.Errors[0] should've had the URL redacted because ui.hide-url is set to true")
}
}
func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
conditionBody := Condition("[BODY] == pat(*.*.*.*)")
endpoint := Endpoint{
Name: "example",
URL: "8.8.8.8",
DNSConfig: &dns.Config{
QueryType: "A",
QueryName: "example.com.",
},
Conditions: []Condition{conditionSuccess, conditionBody},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' and '%s' should have been a success", conditionSuccess, conditionBody)
}
if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true")
}
if !result.Success {
t.Error("Because all conditions passed, this should have been a success")
}
}
func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
scenarios := []struct {
name string
endpoint Endpoint
conditions []Condition
success bool
}{
{
name: "ssh-success",
endpoint: Endpoint{
Name: "ssh-success",
URL: "ssh://localhost",
SSHConfig: &ssh.Config{
Username: "scenario",
Password: "scenario",
},
Body: "{ \"command\": \"uptime\" }",
},
conditions: []Condition{Condition("[STATUS] == 0")},
success: true,
},
{
name: "ssh-failure",
endpoint: Endpoint{
Name: "ssh-failure",
URL: "ssh://localhost",
SSHConfig: &ssh.Config{
Username: "scenario",
Password: "scenario",
},
Body: "{ \"command\": \"uptime\" }",
},
conditions: []Condition{Condition("[STATUS] == 1")},
success: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
scenario.endpoint.ValidateAndSetDefaults()
scenario.endpoint.Conditions = scenario.conditions
result := scenario.endpoint.EvaluateHealth()
if result.Success != scenario.success {
t.Errorf("Expected success to be %v, but was %v", scenario.success, result.Success)
}
})
}
}
func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
endpoint := Endpoint{
Name: "icmp-test",
URL: "icmp://127.0.0.1",
Conditions: []Condition{"[CONNECTED] == true"},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' should have been a success", endpoint.Conditions[0])
}
if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true")
}
if !result.Success {
t.Error("Because all conditions passed, this should have been a success")
}
}
func TestEndpoint_DisplayName(t *testing.T) {
if endpoint := (Endpoint{Name: "n"}); endpoint.DisplayName() != "n" {
t.Error("endpoint.DisplayName() should've been 'n', but was", endpoint.DisplayName())
}
if endpoint := (Endpoint{Group: "g", Name: "n"}); endpoint.DisplayName() != "g/n" {
t.Error("endpoint.DisplayName() should've been 'g/n', but was", endpoint.DisplayName())
}
}
func TestEndpoint_getIP(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-url-test",
URL: "",
Conditions: []Condition{"[CONNECTED] == true"},
}
result := &Result{}
endpoint.getIP(result)
if len(result.Errors) == 0 {
t.Error("endpoint.getIP(result) should've thrown an error because the URL is invalid, thus cannot be parsed")
}
}
func TestEndpoint_needsToReadBody(t *testing.T) {
statusCondition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP")
bodyConditionWithLength := Condition("len([BODY].tags) > 0")
if (&Endpoint{Conditions: []Condition{statusCondition}}).needsToReadBody() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []Condition{bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Endpoint{Conditions: []Condition{statusCondition, bodyCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
if !(&Endpoint{Conditions: []Condition{bodyCondition, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
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) {
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveDomainExpiration() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[DOMAIN_EXPIRATION] < 720h"}}).needsToRetrieveDomainExpiration() {
t.Error("expected true, got false")
}
}
func TestEndpoint_needsToRetrieveIP(t *testing.T) {
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveIP() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[IP] == 127.0.0.1"}}).needsToRetrieveIP() {
t.Error("expected true, got false")
}
}
func TestEndpoint_preprocessWithContext(t *testing.T) {
// Import the gontext package for creating test contexts
// This test thoroughly exercises the replaceContextPlaceholders function
tests := []struct {
name string
endpoint *Endpoint
context map[string]interface{}
expectedURL string
expectedBody string
expectedHeaders map[string]string
expectedErrorCount int
expectedErrorContains []string
}{
{
name: "successful_url_replacement",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].userId",
Body: "",
},
context: map[string]interface{}{
"userId": "12345",
},
expectedURL: "https://api.example.com/users/12345",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "successful_body_replacement",
endpoint: &Endpoint{
URL: "https://api.example.com",
Body: `{"userId": "[CONTEXT].userId", "action": "update"}`,
},
context: map[string]interface{}{
"userId": "67890",
},
expectedURL: "https://api.example.com",
expectedBody: `{"userId": "67890", "action": "update"}`,
expectedErrorCount: 0,
},
{
name: "successful_header_replacement",
endpoint: &Endpoint{
URL: "https://api.example.com",
Body: "",
Headers: map[string]string{
"Authorization": "Bearer [CONTEXT].token",
"X-User-ID": "[CONTEXT].userId",
},
},
context: map[string]interface{}{
"token": "abc123token",
"userId": "user123",
},
expectedURL: "https://api.example.com",
expectedBody: "",
expectedHeaders: map[string]string{
"Authorization": "Bearer abc123token",
"X-User-ID": "user123",
},
expectedErrorCount: 0,
},
{
name: "multiple_placeholders_in_url",
endpoint: &Endpoint{
URL: "https://[CONTEXT].host/api/v[CONTEXT].version/users/[CONTEXT].userId",
Body: "",
},
context: map[string]interface{}{
"host": "api.example.com",
"version": "2",
"userId": "12345",
},
expectedURL: "https://api.example.com/api/v2/users/12345",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "nested_context_path",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].user.id",
Body: `{"name": "[CONTEXT].user.name"}`,
},
context: map[string]interface{}{
"user": map[string]interface{}{
"id": "nested123",
"name": "John Doe",
},
},
expectedURL: "https://api.example.com/users/nested123",
expectedBody: `{"name": "John Doe"}`,
expectedErrorCount: 0,
},
{
name: "url_context_not_found",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].missingUserId",
Body: "",
},
context: map[string]interface{}{
"userId": "12345", // different key
},
expectedURL: "https://api.example.com/users/[CONTEXT].missingUserId",
expectedBody: "",
expectedErrorCount: 1,
expectedErrorContains: []string{"path 'missingUserId' not found"},
},
{
name: "body_context_not_found",
endpoint: &Endpoint{
URL: "https://api.example.com",
Body: `{"userId": "[CONTEXT].missingUserId"}`,
},
context: map[string]interface{}{
"userId": "12345", // different key
},
expectedURL: "https://api.example.com",
expectedBody: `{"userId": "[CONTEXT].missingUserId"}`,
expectedErrorCount: 1,
expectedErrorContains: []string{"path 'missingUserId' not found"},
},
{
name: "header_context_not_found",
endpoint: &Endpoint{
URL: "https://api.example.com",
Body: "",
Headers: map[string]string{
"Authorization": "Bearer [CONTEXT].missingToken",
},
},
context: map[string]interface{}{
"token": "validtoken", // different key
},
expectedURL: "https://api.example.com",
expectedBody: "",
expectedHeaders: map[string]string{
"Authorization": "Bearer [CONTEXT].missingToken",
},
expectedErrorCount: 1,
expectedErrorContains: []string{"path 'missingToken' not found"},
},
{
name: "multiple_missing_context_paths",
endpoint: &Endpoint{
URL: "https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId",
Body: `{"token": "[CONTEXT].missingToken"}`,
},
context: map[string]interface{}{
"validKey": "validValue",
},
expectedURL: "https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId",
expectedBody: `{"token": "[CONTEXT].missingToken"}`,
expectedErrorCount: 2, // 1 for URL (both placeholders), 1 for Body
expectedErrorContains: []string{
"path 'missingHost' not found",
"path 'missingUserId' not found",
"path 'missingToken' not found",
},
},
{
name: "mixed_valid_and_invalid_placeholders",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].userId/posts/[CONTEXT].missingPostId",
Body: `{"userId": "[CONTEXT].userId", "action": "[CONTEXT].missingAction"}`,
},
context: map[string]interface{}{
"userId": "12345",
},
expectedURL: "https://api.example.com/users/12345/posts/[CONTEXT].missingPostId",
expectedBody: `{"userId": "12345", "action": "[CONTEXT].missingAction"}`,
expectedErrorCount: 2,
expectedErrorContains: []string{
"path 'missingPostId' not found",
"path 'missingAction' not found",
},
},
{
name: "nil_context",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].userId",
Body: "",
},
context: nil,
expectedURL: "https://api.example.com/users/[CONTEXT].userId",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "empty_context",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].userId",
Body: "",
},
context: map[string]interface{}{},
expectedURL: "https://api.example.com/users/[CONTEXT].userId",
expectedBody: "",
expectedErrorCount: 1,
expectedErrorContains: []string{"path 'userId' not found"},
},
{
name: "special_characters_in_context_values",
endpoint: &Endpoint{
URL: "https://api.example.com/search?q=[CONTEXT].query",
Body: "",
},
context: map[string]interface{}{
"query": "hello world & special chars!",
},
expectedURL: "https://api.example.com/search?q=hello world & special chars!",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "numeric_context_values",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].userId/limit/[CONTEXT].limit",
Body: "",
},
context: map[string]interface{}{
"userId": 12345,
"limit": 100,
},
expectedURL: "https://api.example.com/users/12345/limit/100",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "boolean_context_values",
endpoint: &Endpoint{
URL: "https://api.example.com",
Body: `{"enabled": [CONTEXT].enabled, "active": [CONTEXT].active}`,
},
context: map[string]interface{}{
"enabled": true,
"active": false,
},
expectedURL: "https://api.example.com",
expectedBody: `{"enabled": true, "active": false}`,
expectedErrorCount: 0,
},
{
name: "no_context_placeholders",
endpoint: &Endpoint{
URL: "https://api.example.com/health",
Body: `{"status": "check"}`,
Headers: map[string]string{
"Content-Type": "application/json",
},
},
context: map[string]interface{}{
"userId": "12345",
},
expectedURL: "https://api.example.com/health",
expectedBody: `{"status": "check"}`,
expectedHeaders: map[string]string{
"Content-Type": "application/json",
},
expectedErrorCount: 0,
},
{
name: "deeply_nested_context_path",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].response.data.user.id",
Body: "",
},
context: map[string]interface{}{
"response": map[string]interface{}{
"data": map[string]interface{}{
"user": map[string]interface{}{
"id": "deep123",
},
},
},
},
expectedURL: "https://api.example.com/users/deep123",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "invalid_nested_context_path",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].response.missing.path",
Body: "",
},
context: map[string]interface{}{
"response": map[string]interface{}{
"data": "value",
},
},
expectedURL: "https://api.example.com/users/[CONTEXT].response.missing.path",
expectedBody: "",
expectedErrorCount: 1,
expectedErrorContains: []string{"path 'response.missing.path' not found"},
},
{
name: "hyphen_support_in_simple_keys",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].user-id",
Body: `{"api-key": "[CONTEXT].api-key", "user-name": "[CONTEXT].user-name"}`,
},
context: map[string]interface{}{
"user-id": "user-12345",
"api-key": "key-abcdef",
"user-name": "john-doe",
},
expectedURL: "https://api.example.com/users/user-12345",
expectedBody: `{"api-key": "key-abcdef", "user-name": "john-doe"}`,
expectedErrorCount: 0,
},
{
name: "hyphen_support_in_headers",
endpoint: &Endpoint{
URL: "https://api.example.com",
Body: "",
Headers: map[string]string{
"X-API-Key": "[CONTEXT].api-key",
"X-User-ID": "[CONTEXT].user-id",
"Content-Type": "[CONTEXT].content-type",
},
},
context: map[string]interface{}{
"api-key": "secret-key-123",
"user-id": "user-456",
"content-type": "application-json",
},
expectedURL: "https://api.example.com",
expectedBody: "",
expectedHeaders: map[string]string{
"X-API-Key": "secret-key-123",
"X-User-ID": "user-456",
"Content-Type": "application-json",
},
expectedErrorCount: 0,
},
{
name: "mixed_hyphens_underscores_and_dots",
endpoint: &Endpoint{
URL: "https://api.example.com/[CONTEXT].service-name/[CONTEXT].user_data.user-id",
Body: `{"tenant-id": "[CONTEXT].tenant_config.tenant-id"}`,
},
context: map[string]interface{}{
"service-name": "auth-service",
"user_data": map[string]interface{}{
"user-id": "user-789",
},
"tenant_config": map[string]interface{}{
"tenant-id": "tenant-abc-123",
},
},
expectedURL: "https://api.example.com/auth-service/user-789",
expectedBody: `{"tenant-id": "tenant-abc-123"}`,
expectedErrorCount: 0,
},
{
name: "hyphen_in_nested_paths",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].auth-response.user-data.profile-id",
Body: "",
},
context: map[string]interface{}{
"auth-response": map[string]interface{}{
"user-data": map[string]interface{}{
"profile-id": "profile-xyz-789",
},
},
},
expectedURL: "https://api.example.com/users/profile-xyz-789",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "missing_hyphenated_context_key",
endpoint: &Endpoint{
URL: "https://api.example.com/users/[CONTEXT].missing-user-id",
Body: `{"api-key": "[CONTEXT].missing-api-key"}`,
},
context: map[string]interface{}{
"user-id": "valid-user", // different key
},
expectedURL: "https://api.example.com/users/[CONTEXT].missing-user-id",
expectedBody: `{"api-key": "[CONTEXT].missing-api-key"}`,
expectedErrorCount: 2,
expectedErrorContains: []string{"path 'missing-user-id' not found", "path 'missing-api-key' not found"},
},
{
name: "multiple_hyphens_in_single_key",
endpoint: &Endpoint{
URL: "https://api.example.com/[CONTEXT].multi-hyphen-key-name",
Body: "",
},
context: map[string]interface{}{
"multi-hyphen-key-name": "value-with-multiple-hyphens",
},
expectedURL: "https://api.example.com/value-with-multiple-hyphens",
expectedBody: "",
expectedErrorCount: 0,
},
{
name: "hyphens_with_numeric_values",
endpoint: &Endpoint{
URL: "https://api.example.com/limit/[CONTEXT].max-items",
Body: `{"timeout-ms": [CONTEXT].timeout-ms, "retry-count": [CONTEXT].retry-count}`,
},
context: map[string]interface{}{
"max-items": 100,
"timeout-ms": 5000,
"retry-count": 3,
},
expectedURL: "https://api.example.com/limit/100",
expectedBody: `{"timeout-ms": 5000, "retry-count": 3}`,
expectedErrorCount: 0,
},
{
name: "hyphens_with_boolean_values",
endpoint: &Endpoint{
URL: "https://api.example.com",
Body: `{"enable-feature": [CONTEXT].enable-feature, "disable-cache": [CONTEXT].disable-cache}`,
},
context: map[string]interface{}{
"enable-feature": true,
"disable-cache": false,
},
expectedURL: "https://api.example.com",
expectedBody: `{"enable-feature": true, "disable-cache": false}`,
expectedErrorCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Import gontext package for creating context
var ctx *gontext.Gontext
if tt.context != nil {
ctx = gontext.New(tt.context)
}
// Create a new Result to capture errors
result := &Result{}
// Call preprocessWithContext
processed := tt.endpoint.preprocessWithContext(result, ctx)
// Verify URL
if processed.URL != tt.expectedURL {
t.Errorf("URL mismatch:\nexpected: %s\nactual: %s", tt.expectedURL, processed.URL)
}
// Verify Body
if processed.Body != tt.expectedBody {
t.Errorf("Body mismatch:\nexpected: %s\nactual: %s", tt.expectedBody, processed.Body)
}
// Verify Headers
if tt.expectedHeaders != nil {
if processed.Headers == nil {
t.Error("Expected headers but got nil")
} else {
for key, expectedValue := range tt.expectedHeaders {
if actualValue, exists := processed.Headers[key]; !exists {
t.Errorf("Expected header %s not found", key)
} else if actualValue != expectedValue {
t.Errorf("Header %s mismatch:\nexpected: %s\nactual: %s", key, expectedValue, actualValue)
}
}
}
}
// Verify error count
if len(result.Errors) != tt.expectedErrorCount {
t.Errorf("Error count mismatch:\nexpected: %d\nactual: %d\nerrors: %v", tt.expectedErrorCount, len(result.Errors), result.Errors)
}
// Verify error messages contain expected strings
if tt.expectedErrorContains != nil {
actualErrors := strings.Join(result.Errors, " ")
for _, expectedError := range tt.expectedErrorContains {
if !strings.Contains(actualErrors, expectedError) {
t.Errorf("Expected error containing '%s' not found in: %v", expectedError, result.Errors)
}
}
}
// Verify original endpoint is not modified
if tt.endpoint.URL != ((&Endpoint{URL: tt.endpoint.URL, Body: tt.endpoint.Body, Headers: tt.endpoint.Headers}).URL) {
t.Error("Original endpoint was modified")
}
})
}
}
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: "",
},
{
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: "",
},
{
name: "hide-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideErrors: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: false,
},
{
name: "dont-resolve-failed-conditions",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{DontResolveFailedConditions: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
checkConditions: true,
expectConditions: true,
},
{
name: "multiple-hide-features",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{HideConditions: true, HideHostname: true, HideErrors: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
checkConditions: true,
expectConditions: false,
checkHostname: true,
expectHostname: "",
checkErrors: true,
expectErrors: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.mockResponse != nil {
mockClient := &http.Client{Transport: tt.mockResponse}
if tt.endpoint.ClientConfig != nil && tt.endpoint.ClientConfig.Timeout > 0 {
mockClient.Timeout = tt.endpoint.ClientConfig.Timeout
}
client.InjectHTTPClient(mockClient)
} else {
client.InjectHTTPClient(nil)
}
err := tt.endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
}
result := tt.endpoint.EvaluateHealth()
if tt.checkHostname {
if result.Hostname != tt.expectHostname {
t.Errorf("Expected hostname '%s', got '%s'", tt.expectHostname, result.Hostname)
}
}
if tt.checkErrors {
hasErrors := len(result.Errors) > 0
if hasErrors != tt.expectErrors {
t.Errorf("Expected errors=%v, got errors=%v (actual errors: %v)", tt.expectErrors, hasErrors, result.Errors)
}
if tt.checkErrorContent != "" && len(result.Errors) > 0 {
found := false
for _, err := range result.Errors {
if strings.Contains(err, tt.checkErrorContent) {
found = true
break
}
}
if !found {
t.Errorf("Expected error to contain '%s', but got: %v", tt.checkErrorContent, result.Errors)
}
}
}
if tt.checkConditions {
hasConditions := len(result.ConditionResults) > 0
if hasConditions != tt.expectConditions {
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
}
}
})
}
}
================================================
FILE: config/endpoint/event.go
================================================
package endpoint
import (
"time"
)
// Event is something that happens at a specific time
type Event struct {
// Type is the kind of event
Type EventType `json:"type"`
// Timestamp is the moment at which the event happened
Timestamp time.Time `json:"timestamp"`
}
// EventType is, uh, the types of events?
type EventType string
var (
// EventStart is a type of event that represents when an endpoint starts being monitored
EventStart EventType = "START"
// EventHealthy is a type of event that represents an endpoint passing all of its conditions
EventHealthy EventType = "HEALTHY"
// EventUnhealthy is a type of event that represents an endpoint failing one or more of its conditions
EventUnhealthy EventType = "UNHEALTHY"
)
// NewEventFromResult creates an Event from a Result
func NewEventFromResult(result *Result) *Event {
event := &Event{Timestamp: result.Timestamp}
if result.Success {
event.Type = EventHealthy
} else {
event.Type = EventUnhealthy
}
return event
}
================================================
FILE: config/endpoint/event_test.go
================================================
package endpoint
import (
"testing"
)
func TestNewEventFromResult(t *testing.T) {
if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy {
t.Error("expected event.Type to be EventHealthy")
}
if event := NewEventFromResult(&Result{Success: false}); event.Type != EventUnhealthy {
t.Error("expected event.Type to be EventUnhealthy")
}
}
================================================
FILE: config/endpoint/external_endpoint.go
================================================
package endpoint
import (
"errors"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint/heartbeat"
"github.com/TwiN/gatus/v5/config/key"
"github.com/TwiN/gatus/v5/config/maintenance"
)
var (
// ErrExternalEndpointWithNoToken is the error with which Gatus will panic if an external endpoint is configured without a token.
ErrExternalEndpointWithNoToken = errors.New("you must specify a token for each external endpoint")
// ErrExternalEndpointHeartbeatIntervalTooLow is the error with which Gatus will panic if an external endpoint's heartbeat interval is less than 10 seconds.
ErrExternalEndpointHeartbeatIntervalTooLow = errors.New("heartbeat interval must be at least 10 seconds")
)
// ExternalEndpoint is an endpoint whose result is pushed from outside Gatus, which means that
// said endpoints are not monitored by Gatus itself; Gatus only displays their results and takes
// care of alerting
type ExternalEndpoint struct {
// Enabled defines whether to enable the monitoring of the endpoint
Enabled *bool `yaml:"enabled,omitempty"`
// Name of the endpoint. Can be anything.
Name string `yaml:"name"`
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
Group string `yaml:"group,omitempty"`
// Token is the bearer token that must be provided through the Authorization header to push results to the endpoint
Token string `yaml:"token,omitempty"`
// Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
// Heartbeat is the configuration that checks if the external endpoint has received new results when it should have.
Heartbeat heartbeat.Config `yaml:"heartbeat,omitempty"`
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
NumberOfFailuresInARow int `yaml:"-"`
// NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int `yaml:"-"`
}
// ValidateAndSetDefaults validates the ExternalEndpoint and sets the default values
func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error {
if err := validateEndpointNameGroupAndAlerts(externalEndpoint.Name, externalEndpoint.Group, externalEndpoint.Alerts); err != nil {
return err
}
if len(externalEndpoint.Token) == 0 {
return ErrExternalEndpointWithNoToken
}
if externalEndpoint.Heartbeat.Interval != 0 && externalEndpoint.Heartbeat.Interval < 10*time.Second {
// If the heartbeat interval is set (non-0), it must be at least 10 seconds.
return ErrExternalEndpointHeartbeatIntervalTooLow
}
return nil
}
// IsEnabled returns whether the endpoint is enabled or not
func (externalEndpoint *ExternalEndpoint) IsEnabled() bool {
if externalEndpoint.Enabled == nil {
return true
}
return *externalEndpoint.Enabled
}
// DisplayName returns an identifier made up of the Name and, if not empty, the Group.
func (externalEndpoint *ExternalEndpoint) DisplayName() string {
if len(externalEndpoint.Group) > 0 {
return externalEndpoint.Group + "/" + externalEndpoint.Name
}
return externalEndpoint.Name
}
// Key returns the unique key for the Endpoint
func (externalEndpoint *ExternalEndpoint) Key() string {
return key.ConvertGroupAndNameToKey(externalEndpoint.Group, externalEndpoint.Name)
}
// ToEndpoint converts the ExternalEndpoint to an Endpoint
func (externalEndpoint *ExternalEndpoint) ToEndpoint() *Endpoint {
endpoint := &Endpoint{
Enabled: externalEndpoint.Enabled,
Name: externalEndpoint.Name,
Group: externalEndpoint.Group,
Alerts: externalEndpoint.Alerts,
NumberOfFailuresInARow: externalEndpoint.NumberOfFailuresInARow,
NumberOfSuccessesInARow: externalEndpoint.NumberOfSuccessesInARow,
}
return endpoint
}
================================================
FILE: config/endpoint/external_endpoint_test.go
================================================
package endpoint
import (
"testing"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint/heartbeat"
"github.com/TwiN/gatus/v5/config/maintenance"
)
func TestExternalEndpoint_ValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
endpoint *ExternalEndpoint
wantErr error
}{
{
name: "valid-external-endpoint",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Group: "test-group",
Token: "valid-token",
},
wantErr: nil,
},
{
name: "valid-external-endpoint-with-heartbeat",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Token: "valid-token",
Heartbeat: heartbeat.Config{
Interval: 30 * time.Second,
},
},
wantErr: nil,
},
{
name: "missing-token",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Group: "test-group",
},
wantErr: ErrExternalEndpointWithNoToken,
},
{
name: "empty-token",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Token: "",
},
wantErr: ErrExternalEndpointWithNoToken,
},
{
name: "heartbeat-interval-too-low",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Token: "valid-token",
Heartbeat: heartbeat.Config{
Interval: 5 * time.Second, // Less than 10 seconds
},
},
wantErr: ErrExternalEndpointHeartbeatIntervalTooLow,
},
{
name: "heartbeat-interval-exactly-10-seconds",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Token: "valid-token",
Heartbeat: heartbeat.Config{
Interval: 10 * time.Second,
},
},
wantErr: nil,
},
{
name: "heartbeat-interval-zero-is-allowed",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Token: "valid-token",
Heartbeat: heartbeat.Config{
Interval: 0, // Zero means no heartbeat monitoring
},
},
wantErr: nil,
},
{
name: "missing-name",
endpoint: &ExternalEndpoint{
Group: "test-group",
Token: "valid-token",
},
wantErr: ErrEndpointWithNoName,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.endpoint.ValidateAndSetDefaults()
if tt.wantErr != nil {
if err == nil {
t.Errorf("Expected error %v, but got none", tt.wantErr)
return
}
if err.Error() != tt.wantErr.Error() {
t.Errorf("Expected error %v, got %v", tt.wantErr, err)
}
} else {
if err != nil {
t.Errorf("Expected no error, but got %v", err)
}
}
})
}
}
func TestExternalEndpoint_IsEnabled(t *testing.T) {
tests := []struct {
name string
enabled *bool
expected bool
}{
{
name: "nil-enabled-defaults-to-true",
enabled: nil,
expected: true,
},
{
name: "explicitly-enabled",
enabled: boolPtr(true),
expected: true,
},
{
name: "explicitly-disabled",
enabled: boolPtr(false),
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
endpoint := &ExternalEndpoint{
Name: "test-endpoint",
Token: "test-token",
Enabled: tt.enabled,
}
result := endpoint.IsEnabled()
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestExternalEndpoint_DisplayName(t *testing.T) {
tests := []struct {
name string
endpoint *ExternalEndpoint
expected string
}{
{
name: "with-group",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Group: "test-group",
},
expected: "test-group/test-endpoint",
},
{
name: "without-group",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Group: "",
},
expected: "test-endpoint",
},
{
name: "empty-group-string",
endpoint: &ExternalEndpoint{
Name: "api-health",
Group: "",
},
expected: "api-health",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.endpoint.DisplayName()
if result != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, result)
}
})
}
}
func TestExternalEndpoint_Key(t *testing.T) {
tests := []struct {
name string
endpoint *ExternalEndpoint
expected string
}{
{
name: "with-group",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Group: "test-group",
},
expected: "test-group_test-endpoint",
},
{
name: "without-group",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Group: "",
},
expected: "_test-endpoint",
},
{
name: "special-characters-in-name",
endpoint: &ExternalEndpoint{
Name: "test endpoint with spaces",
Group: "test-group",
},
expected: "test-group_test-endpoint-with-spaces",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.endpoint.Key()
if result != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, result)
}
})
}
}
func TestExternalEndpoint_ToEndpoint(t *testing.T) {
tests := []struct {
name string
externalEndpoint *ExternalEndpoint
}{
{
name: "complete-external-endpoint",
externalEndpoint: &ExternalEndpoint{
Enabled: boolPtr(true),
Name: "test-endpoint",
Group: "test-group",
Token: "test-token",
Alerts: []*alert.Alert{
{
Type: alert.TypeSlack,
},
},
MaintenanceWindows: []*maintenance.Config{
{
Start: "02:00",
Duration: time.Hour,
},
},
NumberOfFailuresInARow: 3,
NumberOfSuccessesInARow: 5,
},
},
{
name: "minimal-external-endpoint",
externalEndpoint: &ExternalEndpoint{
Name: "minimal-endpoint",
Token: "minimal-token",
},
},
{
name: "disabled-external-endpoint",
externalEndpoint: &ExternalEndpoint{
Enabled: boolPtr(false),
Name: "disabled-endpoint",
Token: "disabled-token",
},
},
{
name: "original-test-case",
externalEndpoint: &ExternalEndpoint{
Name: "name",
Group: "group",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.externalEndpoint.ToEndpoint()
// Verify all fields are correctly copied
if result.Enabled != tt.externalEndpoint.Enabled {
t.Errorf("Expected Enabled=%v, got %v", tt.externalEndpoint.Enabled, result.Enabled)
}
if result.Name != tt.externalEndpoint.Name {
t.Errorf("Expected Name=%q, got %q", tt.externalEndpoint.Name, result.Name)
}
if result.Group != tt.externalEndpoint.Group {
t.Errorf("Expected Group=%q, got %q", tt.externalEndpoint.Group, result.Group)
}
if len(result.Alerts) != len(tt.externalEndpoint.Alerts) {
t.Errorf("Expected %d alerts, got %d", len(tt.externalEndpoint.Alerts), len(result.Alerts))
}
if result.NumberOfFailuresInARow != tt.externalEndpoint.NumberOfFailuresInARow {
t.Errorf("Expected NumberOfFailuresInARow=%d, got %d", tt.externalEndpoint.NumberOfFailuresInARow, result.NumberOfFailuresInARow)
}
if result.NumberOfSuccessesInARow != tt.externalEndpoint.NumberOfSuccessesInARow {
t.Errorf("Expected NumberOfSuccessesInARow=%d, got %d", tt.externalEndpoint.NumberOfSuccessesInARow, result.NumberOfSuccessesInARow)
}
// Original test assertions
if tt.externalEndpoint.Key() != result.Key() {
t.Errorf("expected %s, got %s", tt.externalEndpoint.Key(), result.Key())
}
if tt.externalEndpoint.DisplayName() != result.DisplayName() {
t.Errorf("expected %s, got %s", tt.externalEndpoint.DisplayName(), result.DisplayName())
}
// Verify it's a proper Endpoint type
if result == nil {
t.Error("ToEndpoint() returned nil")
}
})
}
}
func TestExternalEndpoint_ValidationEdgeCases(t *testing.T) {
tests := []struct {
name string
endpoint *ExternalEndpoint
wantErr bool
}{
{
name: "very-long-name",
endpoint: &ExternalEndpoint{
Name: "this-is-a-very-long-endpoint-name-that-might-cause-issues-in-some-systems-but-should-be-handled-gracefully",
Token: "valid-token",
},
wantErr: false,
},
{
name: "special-characters-in-name",
endpoint: &ExternalEndpoint{
Name: "test-endpoint@#$%^&*()",
Token: "valid-token",
},
wantErr: false,
},
{
name: "unicode-characters-in-name",
endpoint: &ExternalEndpoint{
Name: "测试端点",
Token: "valid-token",
},
wantErr: false,
},
{
name: "very-long-token",
endpoint: &ExternalEndpoint{
Name: "test-endpoint",
Token: "very-long-token-that-should-still-be-valid-even-though-it-is-extremely-long-and-might-not-be-practical-in-real-world-scenarios",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.endpoint.ValidateAndSetDefaults()
if tt.wantErr && err == nil {
t.Error("Expected error but got none")
}
if !tt.wantErr && err != nil {
t.Errorf("Expected no error but got: %v", err)
}
})
}
}
// Helper function to create bool pointers
func boolPtr(b bool) *bool {
return &b
}
================================================
FILE: config/endpoint/heartbeat/heartbeat.go
================================================
package heartbeat
import "time"
// Config used to check if the external endpoint has received new results when it should have.
// This configuration is used to trigger alerts when an external endpoint has no new results for a defined period of time
type Config struct {
// Interval is the time interval at which Gatus verifies whether the external endpoint has received new results
// If no new result is received within the interval, the endpoint is marked as failed and alerts are triggered
Interval time.Duration `yaml:"interval"`
}
================================================
FILE: config/endpoint/placeholder.go
================================================
package endpoint
import (
"fmt"
"strconv"
"strings"
"github.com/TwiN/gatus/v5/config/gontext"
"github.com/TwiN/gatus/v5/jsonpath"
)
// Placeholders
const (
// StatusPlaceholder is a placeholder for a HTTP status.
//
// Values that could replace the placeholder: 200, 404, 500, ...
StatusPlaceholder = "[STATUS]"
// IPPlaceholder is a placeholder for an IP.
//
// Values that could replace the placeholder: 127.0.0.1, 10.0.0.1, ...
IPPlaceholder = "[IP]"
// DNSRCodePlaceholder is a placeholder for DNS_RCODE
//
// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
DNSRCodePlaceholder = "[DNS_RCODE]"
// ResponseTimePlaceholder is a placeholder for the request response time, in milliseconds.
//
// Values that could replace the placeholder: 1, 500, 1000, ...
ResponseTimePlaceholder = "[RESPONSE_TIME]"
// BodyPlaceholder is a placeholder for the Body of the response
//
// Values that could replace the placeholder: {}, {"data":{"name":"john"}}, ...
BodyPlaceholder = "[BODY]"
// ConnectedPlaceholder is a placeholder for whether a connection was successfully established.
//
// Values that could replace the placeholder: true, false
ConnectedPlaceholder = "[CONNECTED]"
// CertificateExpirationPlaceholder is a placeholder for the duration before certificate expiration, in milliseconds.
//
// Values that could replace the placeholder: 4461677039 (~52 days)
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
// ContextPlaceholder is a placeholder for suite context values
// Usage: [CONTEXT].path.to.value
ContextPlaceholder = "[CONTEXT]"
)
// Functions
const (
// LengthFunctionPrefix is the prefix for the length function
//
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
LengthFunctionPrefix = "len("
// HasFunctionPrefix is the prefix for the has function
//
// Usage: has([BODY].errors) == true
HasFunctionPrefix = "has("
// PatternFunctionPrefix is the prefix for the pattern function
//
// Usage: [IP] == pat(192.168.*.*)
PatternFunctionPrefix = "pat("
// AnyFunctionPrefix is the prefix for the any function
//
// Usage: [IP] == any(1.1.1.1, 1.0.0.1)
AnyFunctionPrefix = "any("
// FunctionSuffix is the suffix for all functions
FunctionSuffix = ")"
)
// Other constants
const (
// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition
InvalidConditionElementSuffix = "(INVALID)"
)
// functionType represents the type of function wrapper
type functionType int
const (
// Note that not all functions are handled here. Only len() and has() directly impact the handler
// e.g. "len([BODY].name) > 0" vs pat() or any(), which would be used like "[BODY].name == pat(john*)"
noFunction functionType = iota
functionLen
functionHas
)
// ResolvePlaceholder resolves all types of placeholders to their string values.
//
// Supported placeholders:
// - [STATUS]: HTTP status code (e.g., "200", "404")
// - [IP]: IP address from the response (e.g., "127.0.0.1")
// - [RESPONSE_TIME]: Response time in milliseconds (e.g., "250")
// - [DNS_RCODE]: DNS response code (e.g., "NOERROR", "NXDOMAIN")
// - [CONNECTED]: Connection status (e.g., "true", "false")
// - [CERTIFICATE_EXPIRATION]: Certificate expiration time in milliseconds
// - [DOMAIN_EXPIRATION]: Domain expiration time in milliseconds
// - [BODY]: Full response body
// - [BODY].path: JSONPath expression on response body (e.g., [BODY].status, [BODY].data[0].name)
// - [CONTEXT].path: Suite context values (e.g., [CONTEXT].user_id, [CONTEXT].session_token)
//
// Function wrappers:
// - len(placeholder): Returns the length of the resolved value
// - has(placeholder): Returns "true" if the placeholder exists and is non-empty, "false" otherwise
//
// Examples:
// - ResolvePlaceholder("[STATUS]", result, nil) → "200"
// - ResolvePlaceholder("len([BODY].items)", result, nil) → "5" (for JSON array with 5 items)
// - ResolvePlaceholder("has([CONTEXT].user_id)", result, ctx) → "true" (if context has user_id)
// - ResolvePlaceholder("[BODY].user.name", result, nil) → "john" (for {"user":{"name":"john"}})
//
// Case-insensitive: All placeholder names are handled case-insensitively, but paths preserve original case.
func ResolvePlaceholder(placeholder string, result *Result, ctx *gontext.Gontext) (string, error) {
placeholder = strings.TrimSpace(placeholder)
originalPlaceholder := placeholder
// Extract function wrapper if present
fn, innerPlaceholder := extractFunctionWrapper(placeholder)
placeholder = innerPlaceholder
// Handle CONTEXT placeholders
uppercasePlaceholder := strings.ToUpper(placeholder)
if strings.HasPrefix(uppercasePlaceholder, ContextPlaceholder) && ctx != nil {
return resolveContextPlaceholder(placeholder, fn, originalPlaceholder, ctx)
}
// Handle basic placeholders (try uppercase first for backward compatibility)
switch uppercasePlaceholder {
case StatusPlaceholder:
return formatWithFunction(strconv.Itoa(result.HTTPStatus), fn), nil
case IPPlaceholder:
return formatWithFunction(result.IP, fn), nil
case ResponseTimePlaceholder:
return formatWithFunction(strconv.FormatInt(result.Duration.Milliseconds(), 10), fn), nil
case DNSRCodePlaceholder:
return formatWithFunction(result.DNSRCode, fn), nil
case ConnectedPlaceholder:
return formatWithFunction(strconv.FormatBool(result.Connected), fn), nil
case CertificateExpirationPlaceholder:
return formatWithFunction(strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10), fn), nil
case DomainExpirationPlaceholder:
return formatWithFunction(strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10), fn), nil
case BodyPlaceholder:
body := strings.TrimSpace(string(result.Body))
if fn == functionHas {
return strconv.FormatBool(len(body) > 0), nil
}
if fn == functionLen {
// For len([BODY]), we need to check if it's JSON and get the actual length
// Use jsonpath to evaluate the root element
_, resolvedLength, err := jsonpath.Eval("", result.Body)
if err == nil {
return strconv.Itoa(resolvedLength), nil
}
// Fall back to string length if not valid JSON
return strconv.Itoa(len(body)), nil
}
return body, nil
}
// Handle JSONPath expressions on BODY (including array indexing)
if strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder+".") || strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder+"[") {
return resolveJSONPathPlaceholder(placeholder, fn, originalPlaceholder, result)
}
// Not a recognized placeholder
if fn != noFunction {
if fn == functionHas {
return "false", nil
}
// For len() with unrecognized placeholder, return with INVALID suffix
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
}
// Return the original placeholder if we can't resolve it
// This allows for literal string comparisons
return originalPlaceholder, nil
}
// extractFunctionWrapper detects and extracts function wrappers (len, has)
func extractFunctionWrapper(placeholder string) (functionType, string) {
if strings.HasPrefix(placeholder, LengthFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) {
inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, LengthFunctionPrefix), FunctionSuffix)
return functionLen, inner
}
if strings.HasPrefix(placeholder, HasFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) {
inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, HasFunctionPrefix), FunctionSuffix)
return functionHas, inner
}
return noFunction, placeholder
}
// resolveJSONPathPlaceholder handles [BODY].path and [BODY][index] placeholders
func resolveJSONPathPlaceholder(placeholder string, fn functionType, originalPlaceholder string, result *Result) (string, error) {
// Extract the path after [BODY] (case insensitive)
uppercasePlaceholder := strings.ToUpper(placeholder)
path := ""
if strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder) {
path = placeholder[len(BodyPlaceholder):]
} else {
path = strings.TrimPrefix(placeholder, BodyPlaceholder)
}
// Remove leading dot if present
path = strings.TrimPrefix(path, ".")
resolvedValue, resolvedLength, err := jsonpath.Eval(path, result.Body)
if fn == functionHas {
return strconv.FormatBool(err == nil), nil
}
if err != nil {
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
}
if fn == functionLen {
return strconv.Itoa(resolvedLength), nil
}
return resolvedValue, nil
}
// resolveContextPlaceholder handles [CONTEXT] placeholder resolution
func resolveContextPlaceholder(placeholder string, fn functionType, originalPlaceholder string, ctx *gontext.Gontext) (string, error) {
contextPath := strings.TrimPrefix(placeholder, ContextPlaceholder)
contextPath = strings.TrimPrefix(contextPath, ".")
if contextPath == "" {
if fn == functionHas {
return "false", nil
}
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
}
value, err := ctx.Get(contextPath)
if fn == functionHas {
return strconv.FormatBool(err == nil), nil
}
if err != nil {
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
}
if fn == functionLen {
switch v := value.(type) {
case string:
return strconv.Itoa(len(v)), nil
case []interface{}:
return strconv.Itoa(len(v)), nil
case map[string]interface{}:
return strconv.Itoa(len(v)), nil
default:
return strconv.Itoa(len(fmt.Sprintf("%v", v))), nil
}
}
return fmt.Sprintf("%v", value), nil
}
// formatWithFunction applies len/has functions to any value
func formatWithFunction(value string, fn functionType) string {
switch fn {
case functionHas:
return strconv.FormatBool(value != "")
case functionLen:
return strconv.Itoa(len(value))
default:
return value
}
}
================================================
FILE: config/endpoint/placeholder_test.go
================================================
package endpoint
import (
"testing"
"time"
"github.com/TwiN/gatus/v5/config/gontext"
)
func TestResolvePlaceholder(t *testing.T) {
result := &Result{
HTTPStatus: 200,
IP: "127.0.0.1",
Duration: 250 * time.Millisecond,
DNSRCode: "NOERROR",
Connected: true,
CertificateExpiration: 30 * 24 * time.Hour,
DomainExpiration: 365 * 24 * time.Hour,
Body: []byte(`{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`),
}
ctx := gontext.New(map[string]interface{}{
"user_id": "abc123",
"session_token": "xyz789",
"array_data": []interface{}{"a", "b", "c"},
"nested": map[string]interface{}{
"value": "test",
},
})
tests := []struct {
name string
placeholder string
expected string
}{
// Basic placeholders
{"status", "[STATUS]", "200"},
{"ip", "[IP]", "127.0.0.1"},
{"response-time", "[RESPONSE_TIME]", "250"},
{"dns-rcode", "[DNS_RCODE]", "NOERROR"},
{"connected", "[CONNECTED]", "true"},
{"certificate-expiration", "[CERTIFICATE_EXPIRATION]", "2592000000"},
{"domain-expiration", "[DOMAIN_EXPIRATION]", "31536000000"},
{"body", "[BODY]", `{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`},
// Case insensitive placeholders
{"status-lowercase", "[status]", "200"},
{"ip-mixed-case", "[Ip]", "127.0.0.1"},
// Function wrappers on basic placeholders
{"len-status", "len([STATUS])", "3"},
{"len-ip", "len([IP])", "9"},
{"has-status", "has([STATUS])", "true"},
{"has-empty", "has()", "false"},
// JSONPath expressions
{"body-status", "[BODY].status", "success"},
{"body-user-name", "[BODY].user.name", "john"},
{"body-user-id", "[BODY].user.id", "123"},
{"len-body-items", "len([BODY].items)", "3"},
{"body-array-index", "[BODY].items[0]", "1"},
{"has-body-status", "has([BODY].status)", "true"},
{"has-body-missing", "has([BODY].missing)", "false"},
// Context placeholders
{"context-user-id", "[CONTEXT].user_id", "abc123"},
{"context-session-token", "[CONTEXT].session_token", "xyz789"},
{"context-nested", "[CONTEXT].nested.value", "test"},
{"len-context-array", "len([CONTEXT].array_data)", "3"},
{"has-context-user-id", "has([CONTEXT].user_id)", "true"},
{"has-context-missing", "has([CONTEXT].missing)", "false"},
// Invalid placeholders
{"unknown-placeholder", "[UNKNOWN]", "[UNKNOWN]"},
{"len-unknown", "len([UNKNOWN])", "len([UNKNOWN]) (INVALID)"},
{"has-unknown", "has([UNKNOWN])", "false"},
{"invalid-jsonpath", "[BODY].invalid.path", "[BODY].invalid.path (INVALID)"},
// Literal strings
{"literal-string", "literal", "literal"},
{"number-string", "123", "123"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := ResolvePlaceholder(test.placeholder, result, ctx)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if actual != test.expected {
t.Errorf("expected '%s', got '%s'", test.expected, actual)
}
})
}
}
func TestResolvePlaceholderWithoutContext(t *testing.T) {
result := &Result{
HTTPStatus: 404,
Body: []byte(`{"error":"not found"}`),
}
tests := []struct {
name string
placeholder string
expected string
}{
{"status-without-context", "[STATUS]", "404"},
{"body-without-context", "[BODY].error", "not found"},
{"context-without-context", "[CONTEXT].user_id", "[CONTEXT].user_id"},
{"has-context-without-context", "has([CONTEXT].user_id)", "false"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := ResolvePlaceholder(test.placeholder, result, nil)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if actual != test.expected {
t.Errorf("expected '%s', got '%s'", test.expected, actual)
}
})
}
}
================================================
FILE: config/endpoint/result.go
================================================
package endpoint
import (
"slices"
"time"
)
// Result of the evaluation of an Endpoint
type Result struct {
// HTTPStatus is the HTTP response status code
HTTPStatus int `json:"status,omitempty"`
// DNSRCode is the response code of a DNS query in a human-readable format
//
// Possible values: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
DNSRCode string `json:"-"`
// Hostname extracted from Endpoint.URL
Hostname string `json:"hostname,omitempty"`
// IP resolved from the Endpoint URL
IP string `json:"-"`
// Connected whether a connection to the host was established successfully
Connected bool `json:"-"`
// Duration time that the request took
Duration time.Duration `json:"duration"`
// Errors encountered during the evaluation of the Endpoint's health
Errors []string `json:"errors,omitempty"`
// ConditionResults are the results of each of the Endpoint's Condition
ConditionResults []*ConditionResult `json:"conditionResults,omitempty"`
// Success whether the result signifies a success or not
Success bool `json:"success"`
// Timestamp when the request was sent
Timestamp time.Time `json:"timestamp"`
// CertificateExpiration is the duration before the certificate expires
CertificateExpiration time.Duration `json:"-"`
// DomainExpiration is the duration before the domain expires
DomainExpiration time.Duration `json:"-"`
// Body is the response body
//
// Note that this field is not persisted in the storage.
// It is used for health evaluation as well as debugging purposes.
Body []byte `json:"-"`
///////////////////////////////////////////////////////////////////////
// Below is used only for the UI and is not persisted in the storage //
///////////////////////////////////////////////////////////////////////
port string `yaml:"-"` // used for endpoints[].ui.hide-port
///////////////////////////////////
// BELOW IS ONLY USED FOR SUITES //
///////////////////////////////////
// Name of the endpoint (ONLY USED FOR SUITES)
// Group is not needed because it's inherited from the suite
Name string `json:"name,omitempty"`
}
// AddError adds an error to the result's list of errors.
// It also ensures that there are no duplicates.
func (r *Result) AddError(error string) {
if !slices.Contains(r.Errors, error) {
r.Errors = append(r.Errors, error+"")
}
}
================================================
FILE: config/endpoint/result_test.go
================================================
package endpoint
import (
"testing"
)
func TestResult_AddError(t *testing.T) {
result := &Result{}
result.AddError("potato")
if len(result.Errors) != 1 {
t.Error("should've had 1 error")
}
result.AddError("potato")
if len(result.Errors) != 1 {
t.Error("should've still had 1 error, because a duplicate error was added")
}
result.AddError("tomato")
if len(result.Errors) != 2 {
t.Error("should've had 2 error")
}
}
================================================
FILE: config/endpoint/ssh/ssh.go
================================================
package ssh
import (
"errors"
)
var (
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
)
type Config struct {
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
PrivateKey string `yaml:"private-key,omitempty"`
}
// Validate the SSH configuration
func (cfg *Config) Validate() error {
// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid
if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return nil
}
// If any authentication method is provided (password or private key), a username is required
if len(cfg.Username) == 0 {
return ErrEndpointWithoutSSHUsername
}
// If a username is provided, require at least a password or a private key
if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return ErrEndpointWithoutSSHAuth
}
return nil
}
================================================
FILE: config/endpoint/ssh/ssh_test.go
================================================
package ssh
import (
"errors"
"testing"
)
func TestSSH_validatePasswordCfg(t *testing.T) {
cfg := &Config{}
if err := cfg.Validate(); err != nil {
t.Error("didn't expect an error")
}
cfg.Username = "username"
if err := cfg.Validate(); err == nil {
t.Error("expected an error")
} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
}
cfg.Password = "password"
if err := cfg.Validate(); err != nil {
t.Errorf("expected no error, got '%v'", err)
}
}
func TestSSH_validatePrivateKeyCfg(t *testing.T) {
t.Run("fail when username missing but private key provided", func(t *testing.T) {
cfg := &Config{PrivateKey: "-----BEGIN"}
if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {
t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err)
}
})
t.Run("success when username with private key", func(t *testing.T) {
cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
if err := cfg.Validate(); err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}
================================================
FILE: config/endpoint/status.go
================================================
package endpoint
import "github.com/TwiN/gatus/v5/config/key"
// Status contains the evaluation Results of an Endpoint
// This is essentially a DTO
type Status struct {
// Name of the endpoint
Name string `json:"name,omitempty"`
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
Group string `json:"group,omitempty"`
// Key of the Endpoint
Key string `json:"key"`
// Results is the list of endpoint evaluation results
Results []*Result `json:"results"`
// Events is a list of events
Events []*Event `json:"events,omitempty"`
// Uptime information on the endpoint's uptime
//
// Used by the memory store.
//
// To retrieve the uptime between two time, use store.GetUptimeByKey.
Uptime *Uptime `json:"-"`
}
// NewStatus creates a new Status
func NewStatus(group, name string) *Status {
return &Status{
Name: name,
Group: group,
Key: key.ConvertGroupAndNameToKey(group, name),
Results: make([]*Result, 0),
Events: make([]*Event, 0),
Uptime: NewUptime(),
}
}
================================================
FILE: config/endpoint/status_test.go
================================================
package endpoint
import (
"testing"
)
func TestNewEndpointStatus(t *testing.T) {
ep := &Endpoint{Name: "name", Group: "group"}
status := NewStatus(ep.Group, ep.Name)
if status.Name != ep.Name {
t.Errorf("expected %s, got %s", ep.Name, status.Name)
}
if status.Group != ep.Group {
t.Errorf("expected %s, got %s", ep.Group, status.Group)
}
if status.Key != "group_name" {
t.Errorf("expected %s, got %s", "group_name", status.Key)
}
}
================================================
FILE: config/endpoint/ui/ui.go
================================================
package ui
import "errors"
// Config is the UI configuration for endpoint.Endpoint
type Config struct {
// HideConditions whether to hide the condition results on the UI
HideConditions bool `yaml:"hide-conditions"`
// HideHostname whether to hide the hostname in the Result
HideHostname bool `yaml:"hide-hostname"`
// HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token.
HideURL bool `yaml:"hide-url"`
// 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"`
// ResolveSuccessfulConditions whether to resolve successful conditions in the Result for display in the UI
ResolveSuccessfulConditions bool `yaml:"resolve-successful-conditions"`
// Badge is the configuration for the badges generated
Badge *Badge `yaml:"badge"`
}
type Badge struct {
ResponseTime *ResponseTime `yaml:"response-time"`
}
type ResponseTime struct {
Thresholds []int `yaml:"thresholds"`
}
var (
ErrInvalidBadgeResponseTimeConfig = errors.New("invalid response time badge configuration: expected parameter 'response-time' to have 5 ascending numerical values")
)
// ValidateAndSetDefaults validates the UI configuration and sets the default values
func (config *Config) ValidateAndSetDefaults() error {
if config.Badge != nil {
if len(config.Badge.ResponseTime.Thresholds) != 5 {
return ErrInvalidBadgeResponseTimeConfig
}
for i := 4; i > 0; i-- {
if config.Badge.ResponseTime.Thresholds[i] < config.Badge.ResponseTime.Thresholds[i-1] {
return ErrInvalidBadgeResponseTimeConfig
}
}
} else {
config.Badge = GetDefaultConfig().Badge
}
return nil
}
// GetDefaultConfig retrieves the default UI configuration
func GetDefaultConfig() *Config {
return &Config{
HideHostname: false,
HideURL: false,
HidePort: false,
HideErrors: false,
DontResolveFailedConditions: false,
ResolveSuccessfulConditions: false,
HideConditions: false,
Badge: &Badge{
ResponseTime: &ResponseTime{
Thresholds: []int{50, 200, 300, 500, 750},
},
},
}
}
================================================
FILE: config/endpoint/ui/ui_test.go
================================================
package ui
import (
"errors"
"testing"
)
func TestValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr error
}{
{
name: "with-valid-config",
config: &Config{
Badge: &Badge{
ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500, 750}},
},
},
wantErr: nil,
},
{
name: "with-invalid-threshold-length",
config: &Config{
Badge: &Badge{
ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500}},
},
},
wantErr: ErrInvalidBadgeResponseTimeConfig,
},
{
name: "with-invalid-thresholds-order",
config: &Config{
Badge: &Badge{ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 500, 300, 750}}},
},
wantErr: ErrInvalidBadgeResponseTimeConfig,
},
{
name: "with-no-badge-configured", // should give default badge cfg
config: &Config{},
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.config.ValidateAndSetDefaults(); !errors.Is(err, tt.wantErr) {
t.Errorf("Expected error %v, got %v", tt.wantErr, err)
}
})
}
}
================================================
FILE: config/endpoint/uptime.go
================================================
package endpoint
// Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself
// and some other statistics
type Uptime struct {
// HourlyStatistics is a map containing metrics collected (value) for every hourly unix timestamps (key)
//
// Used only if the storage type is memory
HourlyStatistics map[int64]*HourlyUptimeStatistics `json:"-"`
}
// HourlyUptimeStatistics is a struct containing all metrics collected over the course of an hour
type HourlyUptimeStatistics struct {
TotalExecutions uint64 // Total number of checks
SuccessfulExecutions uint64 // Number of successful executions
TotalExecutionsResponseTime uint64 // Total response time for all executions in milliseconds
}
// NewUptime creates a new Uptime
func NewUptime() *Uptime {
return &Uptime{
HourlyStatistics: make(map[int64]*HourlyUptimeStatistics),
}
}
================================================
FILE: config/gontext/gontext.go
================================================
package gontext
import (
"errors"
"fmt"
"strings"
"sync"
)
var (
// ErrGontextPathNotFound is returned when a gontext path doesn't exist
ErrGontextPathNotFound = errors.New("gontext path not found")
)
// Gontext holds values that can be shared between endpoints in a suite
type Gontext struct {
mu sync.RWMutex
values map[string]interface{}
}
// New creates a new gontext with initial values
func New(initial map[string]interface{}) *Gontext {
if initial == nil {
initial = make(map[string]interface{})
}
// Create a deep copy to avoid external modifications
values := make(map[string]interface{})
for k, v := range initial {
values[k] = deepCopyValue(v)
}
return &Gontext{
values: values,
}
}
// Get retrieves a value from the gontext using dot notation
func (g *Gontext) Get(path string) (interface{}, error) {
g.mu.RLock()
defer g.mu.RUnlock()
parts := strings.Split(path, ".")
current := interface{}(g.values)
for _, part := range parts {
switch v := current.(type) {
case map[string]interface{}:
val, exists := v[part]
if !exists {
return nil, fmt.Errorf("%w: %s", ErrGontextPathNotFound, path)
}
current = val
default:
return nil, fmt.Errorf("%w: %s", ErrGontextPathNotFound, path)
}
}
return current, nil
}
// Set stores a value in the gontext using dot notation
func (g *Gontext) Set(path string, value interface{}) error {
g.mu.Lock()
defer g.mu.Unlock()
parts := strings.Split(path, ".")
if len(parts) == 0 {
return errors.New("empty path")
}
// Navigate to the parent of the target
current := g.values
for i := 0; i < len(parts)-1; i++ {
part := parts[i]
if next, exists := current[part]; exists {
if nextMap, ok := next.(map[string]interface{}); ok {
current = nextMap
} else {
// Path exists but is not a map, create a new map
newMap := make(map[string]interface{})
current[part] = newMap
current = newMap
}
} else {
// Create intermediate maps
newMap := make(map[string]interface{})
current[part] = newMap
current = newMap
}
}
// Set the final value
current[parts[len(parts)-1]] = value
return nil
}
// GetAll returns a copy of all gontext values
func (g *Gontext) GetAll() map[string]interface{} {
g.mu.RLock()
defer g.mu.RUnlock()
result := make(map[string]interface{})
for k, v := range g.values {
result[k] = deepCopyValue(v)
}
return result
}
// deepCopyValue creates a deep copy of a value
func deepCopyValue(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
newMap := make(map[string]interface{})
for k, v := range val {
newMap[k] = deepCopyValue(v)
}
return newMap
case []interface{}:
newSlice := make([]interface{}, len(val))
for i, v := range val {
newSlice[i] = deepCopyValue(v)
}
return newSlice
default:
// For primitive types, return as-is (they're passed by value anyway)
return val
}
}
================================================
FILE: config/gontext/gontext_test.go
================================================
package gontext
import (
"errors"
"testing"
)
func TestNew(t *testing.T) {
tests := []struct {
name string
initial map[string]interface{}
expected map[string]interface{}
}{
{
name: "nil-input",
initial: nil,
expected: make(map[string]interface{}),
},
{
name: "empty-input",
initial: make(map[string]interface{}),
expected: make(map[string]interface{}),
},
{
name: "simple-values",
initial: map[string]interface{}{
"key1": "value1",
"key2": 42,
},
expected: map[string]interface{}{
"key1": "value1",
"key2": 42,
},
},
{
name: "nested-values",
initial: map[string]interface{}{
"user": map[string]interface{}{
"id": 123,
"name": "John Doe",
},
},
expected: map[string]interface{}{
"user": map[string]interface{}{
"id": 123,
"name": "John Doe",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := New(tt.initial)
if ctx == nil {
t.Error("Expected non-nil gontext")
}
if ctx.values == nil {
t.Error("Expected non-nil values map")
}
// Verify deep copy by modifying original
if tt.initial != nil {
tt.initial["modified"] = "should not appear"
if _, exists := ctx.values["modified"]; exists {
t.Error("Deep copy failed - original map modification affected gontext")
}
}
})
}
}
func TestGontext_Get(t *testing.T) {
ctx := New(map[string]interface{}{
"simple": "value",
"number": 42,
"boolean": true,
"nested": map[string]interface{}{
"level1": map[string]interface{}{
"level2": "deep_value",
},
},
"user": map[string]interface{}{
"id": 123,
"name": "John",
"profile": map[string]interface{}{
"email": "john@example.com",
},
},
})
tests := []struct {
name string
path string
expected interface{}
shouldError bool
errorType error
}{
{
name: "simple-value",
path: "simple",
expected: "value",
shouldError: false,
},
{
name: "number-value",
path: "number",
expected: 42,
shouldError: false,
},
{
name: "boolean-value",
path: "boolean",
expected: true,
shouldError: false,
},
{
name: "nested-value",
path: "nested.level1.level2",
expected: "deep_value",
shouldError: false,
},
{
name: "user-id",
path: "user.id",
expected: 123,
shouldError: false,
},
{
name: "deep-nested-value",
path: "user.profile.email",
expected: "john@example.com",
shouldError: false,
},
{
name: "non-existent-key",
path: "nonexistent",
expected: nil,
shouldError: true,
errorType: ErrGontextPathNotFound,
},
{
name: "non-existent-nested-key",
path: "user.nonexistent",
expected: nil,
shouldError: true,
errorType: ErrGontextPathNotFound,
},
{
name: "invalid-nested-path",
path: "simple.invalid",
expected: nil,
shouldError: true,
errorType: ErrGontextPathNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ctx.Get(tt.path)
if tt.shouldError {
if err == nil {
t.Errorf("Expected error but got none")
}
if tt.errorType != nil && !errors.Is(err, tt.errorType) {
t.Errorf("Expected error type %v, got %v", tt.errorType, err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
}
})
}
}
func TestGontext_Set(t *testing.T) {
tests := []struct {
name string
path string
value interface{}
wantErr bool
}{
{
name: "simple-set",
path: "key",
value: "value",
wantErr: false,
},
{
name: "nested-set",
path: "user.name",
value: "John Doe",
wantErr: false,
},
{
name: "deep-nested-set",
path: "user.profile.email",
value: "john@example.com",
wantErr: false,
},
{
name: "override-primitive-with-nested",
path: "existing.new",
value: "nested_value",
wantErr: false,
},
{
name: "empty-path",
path: "",
value: "value",
wantErr: false, // Actually, empty string creates a single part [""], which is valid
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := New(map[string]interface{}{
"existing": "primitive",
})
err := ctx.Set(tt.path, tt.value)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
// Verify the value was set correctly
result, getErr := ctx.Get(tt.path)
if getErr != nil {
t.Errorf("Error retrieving set value: %v", getErr)
return
}
if result != tt.value {
t.Errorf("Expected %v, got %v", tt.value, result)
}
})
}
}
func TestGontext_SetOverrideBehavior(t *testing.T) {
ctx := New(map[string]interface{}{
"primitive": "value",
"nested": map[string]interface{}{
"key": "existing",
},
})
// Test overriding primitive with nested structure
err := ctx.Set("primitive.new", "nested_value")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Verify the primitive was replaced with a nested structure
result, err := ctx.Get("primitive.new")
if err != nil {
t.Errorf("Error getting nested value: %v", err)
}
if result != "nested_value" {
t.Errorf("Expected 'nested_value', got %v", result)
}
// Test overriding existing nested value
err = ctx.Set("nested.key", "modified")
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
result, err = ctx.Get("nested.key")
if err != nil {
t.Errorf("Error getting modified value: %v", err)
}
if result != "modified" {
t.Errorf("Expected 'modified', got %v", result)
}
}
func TestGontext_GetAll(t *testing.T) {
initial := map[string]interface{}{
"key1": "value1",
"key2": 42,
"nested": map[string]interface{}{
"inner": "value",
},
}
ctx := New(initial)
// Add another value after creation
ctx.Set("key3", "value3")
result := ctx.GetAll()
// Verify all values are present
if result["key1"] != "value1" {
t.Errorf("Expected key1=value1, got %v", result["key1"])
}
if result["key2"] != 42 {
t.Errorf("Expected key2=42, got %v", result["key2"])
}
if result["key3"] != "value3" {
t.Errorf("Expected key3=value3, got %v", result["key3"])
}
// Verify nested values
nested, ok := result["nested"].(map[string]interface{})
if !ok {
t.Error("Expected nested to be map[string]interface{}")
} else if nested["inner"] != "value" {
t.Errorf("Expected nested.inner=value, got %v", nested["inner"])
}
// Verify deep copy - modifying returned map shouldn't affect gontext
result["key1"] = "modified"
original, _ := ctx.Get("key1")
if original != "value1" {
t.Error("GetAll did not return a deep copy - modification affected original")
}
}
func TestGontext_ConcurrentAccess(t *testing.T) {
ctx := New(map[string]interface{}{
"counter": 0,
})
done := make(chan bool, 10)
// Start 5 goroutines that read values
for i := range 5 {
go func(id int) {
for range 100 {
_, err := ctx.Get("counter")
if err != nil {
t.Errorf("Reader %d error: %v", id, err)
}
}
done <- true
}(i)
}
// Start 5 goroutines that write values
for i := range 5 {
go func(id int) {
for j := range 100 {
err := ctx.Set("counter", id*1000+j)
if err != nil {
t.Errorf("Writer %d error: %v", id, err)
}
}
done <- true
}(i)
}
// Wait for all goroutines to complete
for range 10 {
<-done
}
}
func TestDeepCopyValue(t *testing.T) {
tests := []struct {
name string
input interface{}
}{
{
name: "primitive-string",
input: "test",
},
{
name: "primitive-int",
input: 42,
},
{
name: "primitive-bool",
input: true,
},
{
name: "simple-map",
input: map[string]interface{}{
"key": "value",
},
},
{
name: "nested-map",
input: map[string]interface{}{
"nested": map[string]interface{}{
"deep": "value",
},
},
},
{
name: "simple-slice",
input: []interface{}{"a", "b", "c"},
},
{
name: "mixed-slice",
input: []interface{}{
"string",
42,
map[string]interface{}{"nested": "value"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := deepCopyValue(tt.input)
// For maps and slices, verify it's a different object
switch v := tt.input.(type) {
case map[string]interface{}:
resultMap, ok := result.(map[string]interface{})
if !ok {
t.Error("Deep copy didn't preserve map type")
return
}
// Modify original to ensure independence
v["modified"] = "test"
if _, exists := resultMap["modified"]; exists {
t.Error("Deep copy failed - maps are not independent")
}
case []interface{}:
resultSlice, ok := result.([]interface{})
if !ok {
t.Error("Deep copy didn't preserve slice type")
return
}
if len(resultSlice) != len(v) {
t.Error("Deep copy didn't preserve slice length")
}
}
})
}
}
================================================
FILE: config/key/key.go
================================================
package key
import "strings"
// ConvertGroupAndNameToKey converts a group and a name to a key
func ConvertGroupAndNameToKey(groupName, name string) string {
return sanitize(groupName) + "_" + sanitize(name)
}
func sanitize(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.ReplaceAll(s, "/", "-")
s = strings.ReplaceAll(s, "_", "-")
s = strings.ReplaceAll(s, ".", "-")
s = strings.ReplaceAll(s, ",", "-")
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "#", "-")
s = strings.ReplaceAll(s, "+", "-")
s = strings.ReplaceAll(s, "&", "-")
return s
}
================================================
FILE: config/key/key_bench_test.go
================================================
package key
import (
"testing"
)
func BenchmarkConvertGroupAndNameToKey(b *testing.B) {
for n := 0; n < b.N; n++ {
ConvertGroupAndNameToKey("group", "name")
}
}
================================================
FILE: config/key/key_test.go
================================================
package key
import "testing"
func TestConvertGroupAndNameToKey(t *testing.T) {
type Scenario struct {
GroupName string
Name string
ExpectedOutput string
}
scenarios := []Scenario{
{
GroupName: "Core",
Name: "Front End",
ExpectedOutput: "core_front-end",
},
{
GroupName: "Load balancers",
Name: "us-west-2",
ExpectedOutput: "load-balancers_us-west-2",
},
{
GroupName: "a/b test",
Name: "a",
ExpectedOutput: "a-b-test_a",
},
{
GroupName: "",
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) {
output := ConvertGroupAndNameToKey(scenario.GroupName, scenario.Name)
if output != scenario.ExpectedOutput {
t.Errorf("expected '%s', got '%s'", scenario.ExpectedOutput, output)
}
})
}
}
================================================
FILE: config/maintenance/maintenance.go
================================================
package maintenance
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
_ "time/tzdata" // Required for IANA timezone support
)
var (
errInvalidMaintenanceStartFormat = errors.New("invalid maintenance start format: must be hh:mm, between 00:00 and 23:59 inclusively (e.g. 23:00)")
errInvalidMaintenanceDuration = errors.New("invalid maintenance duration: must be bigger than 0 (e.g. 30m)")
errInvalidDayName = fmt.Errorf("invalid value specified for 'on'. supported values are %s", longDayNames)
errInvalidTimezone = errors.New("invalid timezone specified or format not supported. Use IANA timezone format (e.g. America/Sao_Paulo)")
longDayNames = []string{
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
}
)
// Config allows for the configuration of a maintenance period.
// During this maintenance period, no alerts will be sent.
//
// 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,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,omitempty"`
timezoneLocation *time.Location
durationToStartFromMidnight time.Duration
}
func GetDefaultConfig() *Config {
defaultValue := false
return &Config{
Enabled: &defaultValue,
}
}
// IsEnabled returns whether maintenance is enabled or not
func (c *Config) IsEnabled() bool {
if c.Enabled == nil {
return true
}
return *c.Enabled
}
// ValidateAndSetDefaults validates the maintenance configuration and sets the default values if necessary.
//
// Must be called once in the application's lifecycle before IsUnderMaintenance is called, since it
// also sets durationToStartFromMidnight.
func (c *Config) ValidateAndSetDefaults() error {
if c == nil || !c.IsEnabled() {
// Don't waste time validating if maintenance is not enabled.
return nil
}
for _, day := range c.Every {
isDayValid := slices.Contains(longDayNames, day)
if !isDayValid {
return errInvalidDayName
}
}
var err error
c.durationToStartFromMidnight, err = hhmmToDuration(c.Start)
if err != nil {
return err
}
if c.Duration <= 0 || c.Duration > 24*time.Hour {
return errInvalidMaintenanceDuration
}
if 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
}
return nil
}
// IsUnderMaintenance checks whether the endpoints that Gatus monitors are within the configured maintenance window
func (c *Config) IsUnderMaintenance() bool {
if !c.IsEnabled() {
return false
}
now := time.Now()
if c.timezoneLocation != nil {
now = now.In(c.timezoneLocation)
}
adjustedDate := now.Day()
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
// if time in maintenance window is later than now, treat it as yesterday
adjustedDate--
}
// Set to midnight prior to adding duration
dayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location())
hasMaintenanceEveryDay := len(c.Every) == 0
hasMaintenancePeriodScheduledToStartOnThatWeekday := slices.Contains(c.Every, dayWhereMaintenancePeriodWouldStart.Weekday().String())
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
// The day when the maintenance period would start is not scheduled
// to have any maintenance, so we can just return false.
return false
}
startOfMaintenancePeriod := dayWhereMaintenancePeriodWouldStart.Add(c.durationToStartFromMidnight)
endOfMaintenancePeriod := startOfMaintenancePeriod.Add(c.Duration)
return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)
}
func hhmmToDuration(s string) (time.Duration, error) {
if len(s) != 5 {
return 0, errInvalidMaintenanceStartFormat
}
var hours, minutes int
var err error
if hours, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[:2]); err != nil {
return 0, err
}
if minutes, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[3:5]); err != nil {
return 0, err
}
duration := (time.Duration(hours) * time.Hour) + (time.Duration(minutes) * time.Minute)
if hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || duration < 0 || duration >= 24*time.Hour {
return 0, errInvalidMaintenanceStartFormat
}
return duration, nil
}
func extractNumericalValueFromPotentiallyZeroPaddedString(s string) (int, error) {
return strconv.Atoi(strings.TrimPrefix(s, "0"))
}
================================================
FILE: config/maintenance/maintenance_test.go
================================================
package maintenance
import (
"errors"
"fmt"
"strconv"
"testing"
"time"
)
func TestGetDefaultConfig(t *testing.T) {
if *GetDefaultConfig().Enabled {
t.Fatal("expected default config to be disabled by default")
}
}
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
yes, no := true, false
scenarios := []struct {
name string
cfg *Config
expectedError error
}{
{
name: "nil",
cfg: nil,
expectedError: nil,
},
{
name: "disabled",
cfg: &Config{
Enabled: &no,
},
expectedError: nil,
},
{
name: "invalid-day",
cfg: &Config{
Every: []string{"invalid-day"},
},
expectedError: errInvalidDayName,
},
{
name: "invalid-day",
cfg: &Config{
Every: []string{"invalid-day"},
},
expectedError: errInvalidDayName,
},
{
name: "invalid-start-format",
cfg: &Config{
Start: "0000",
},
expectedError: errInvalidMaintenanceStartFormat,
},
{
name: "invalid-start-hours",
cfg: &Config{
Start: "25:00",
},
expectedError: errInvalidMaintenanceStartFormat,
},
{
name: "invalid-start-minutes",
cfg: &Config{
Start: "0:61",
},
expectedError: errInvalidMaintenanceStartFormat,
},
{
name: "invalid-start-minutes-non-numerical",
cfg: &Config{
Start: "00:zz",
},
expectedError: strconv.ErrSyntax,
},
{
name: "invalid-start-hours-non-numerical",
cfg: &Config{
Start: "zz:00",
},
expectedError: strconv.ErrSyntax,
},
{
name: "invalid-duration",
cfg: &Config{
Start: "23:00",
Duration: 0,
},
expectedError: errInvalidMaintenanceDuration,
},
{
name: "invalid-timezone",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
Timezone: "invalid-timezone",
},
expectedError: errInvalidTimezone,
},
{
name: "every-day-at-2300",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
},
expectedError: nil,
},
{
name: "every-day-explicitly-at-2300",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expectedError: nil,
},
{
name: "every-monday-at-0000",
cfg: &Config{
Start: "00:00",
Duration: 30 * time.Minute,
Every: []string{"Monday"},
},
expectedError: nil,
},
{
name: "every-friday-and-sunday-at-0000-explicitly-enabled",
cfg: &Config{
Enabled: &yes,
Start: "08:00",
Duration: 8 * time.Hour,
Every: []string{"Friday", "Sunday"},
},
expectedError: nil,
},
{
name: "timezone-amsterdam",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
Timezone: "Europe/Amsterdam",
},
expectedError: nil,
},
{
name: "timezone-cet",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
Timezone: "CET",
},
expectedError: nil,
},
{
name: "timezone-etc-plus-5",
cfg: &Config{
Start: "23:00",
Duration: time.Hour,
Timezone: "Etc/GMT+5",
},
expectedError: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected %v, got %v", scenario.expectedError, err)
}
})
}
}
func TestConfig_IsUnderMaintenance(t *testing.T) {
yes, no := true, false
now := time.Now().UTC()
scenarios := []struct {
name string
cfg *Config
expectedUnderMaintenance bool
}{
{
name: "disabled",
cfg: &Config{
Enabled: &no,
},
expectedUnderMaintenance: false,
},
{
name: "under-maintenance-explicitly-enabled",
cfg: &Config{
Enabled: &yes,
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 2 * time.Hour,
},
expectedUnderMaintenance: true,
},
{
name: "under-maintenance-starting-now-for-2h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 2 * time.Hour,
},
expectedUnderMaintenance: true,
},
{
name: "under-maintenance-starting-now-for-8h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 8 * time.Hour,
},
expectedUnderMaintenance: true,
},
{
name: "under-maintenance-starting-now-for-8h-explicit-days",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 8 * time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expectedUnderMaintenance: true,
},
{
name: "under-maintenance-starting-now-for-23h-explicit-days",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 23 * time.Hour,
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
},
expectedUnderMaintenance: true,
},
{
name: "under-maintenance-starting-4h-ago-for-8h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
Duration: 8 * time.Hour,
},
expectedUnderMaintenance: true,
},
{
name: "under-maintenance-starting-22h-ago-for-23h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
Duration: 23 * time.Hour,
},
expectedUnderMaintenance: true,
},
{
name: "under-maintenance-starting-22h-ago-for-24h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
Duration: 24 * time.Hour,
},
expectedUnderMaintenance: true,
},
{
name: "under-maintenance-amsterdam-timezone-starting-now-for-2h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", inTimezone(now, "Europe/Amsterdam", t).Hour()),
Duration: 2 * time.Hour,
Timezone: "Europe/Amsterdam",
},
expectedUnderMaintenance: true,
},
{
name: "under-maintenance-perth-timezone-starting-now-for-2h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", inTimezone(now, "Australia/Perth", t).Hour()),
Duration: 2 * time.Hour,
Timezone: "Australia/Perth",
},
expectedUnderMaintenance: true,
},
{
name: "not-under-maintenance-los-angeles-timezone-starting-now-for-2h-today",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 2 * time.Hour,
Timezone: "America/Los_Angeles",
Every: []string{now.Weekday().String()},
},
expectedUnderMaintenance: false,
},
{
name: "under-maintenance-utc-timezone-starting-now-for-2h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 2 * time.Hour,
Timezone: "UTC",
},
expectedUnderMaintenance: true,
},
{
name: "not-under-maintenance-starting-4h-ago-for-3h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
Duration: 3 * time.Hour,
},
expectedUnderMaintenance: false,
},
{
name: "not-under-maintenance-starting-5h-ago-for-1h",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)),
Duration: time.Hour,
},
expectedUnderMaintenance: false,
},
{
name: "not-under-maintenance-today",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: time.Hour,
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
},
expectedUnderMaintenance: false,
},
{
name: "not-under-maintenance-today-with-24h-duration",
cfg: &Config{
Start: fmt.Sprintf("%02d:00", now.Hour()),
Duration: 24 * time.Hour,
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
},
expectedUnderMaintenance: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
t.Log(scenario.cfg.Start)
t.Log(now)
if err := scenario.cfg.ValidateAndSetDefaults(); err != nil {
t.Fatal("validation shouldn't have returned an error, got", err)
}
isUnderMaintenance := scenario.cfg.IsUnderMaintenance()
if isUnderMaintenance != scenario.expectedUnderMaintenance {
t.Errorf("expectedUnderMaintenance %v, got %v", scenario.expectedUnderMaintenance, isUnderMaintenance)
t.Logf("start=%v; duration=%v; now=%v", scenario.cfg.Start, scenario.cfg.Duration, time.Now().UTC())
}
})
}
}
func normalizeHour(hour int) int {
if hour < 0 {
return hour + 24
}
return hour
}
func inTimezone(passedTime time.Time, timezone string, t *testing.T) time.Time {
timezoneLocation, err := time.LoadLocation(timezone)
if err != nil {
t.Fatalf("timezone %s did not load", timezone)
}
return passedTime.In(timezoneLocation)
}
================================================
FILE: config/remote/remote.go
================================================
package remote
import (
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/logr"
)
// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.
// For more information, see https://github.com/TwiN/gatus/issues/64
type Config struct {
// Instances is a list of remote instances to retrieve endpoint statuses from.
Instances []Instance `yaml:"instances,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
type Instance struct {
EndpointPrefix string `yaml:"endpoint-prefix"`
URL string `yaml:"url"`
}
func (c *Config) ValidateAndSetDefaults() error {
if c.ClientConfig == nil {
c.ClientConfig = client.GetDefaultConfig()
} else {
if err := c.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if len(c.Instances) > 0 {
logr.Warn("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
logr.Warn("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
}
return nil
}
================================================
FILE: config/suite/result.go
================================================
package suite
import (
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// Result represents the result of a suite execution
type Result struct {
// Name of the suite
Name string `json:"name,omitempty"`
// Group of the suite
Group string `json:"group,omitempty"`
// Success indicates whether all required endpoints succeeded
Success bool `json:"success"`
// Timestamp is when the suite execution started
Timestamp time.Time `json:"timestamp"`
// Duration is how long the entire suite execution took
Duration time.Duration `json:"duration"`
// EndpointResults contains the results of each endpoint execution
EndpointResults []*endpoint.Result `json:"endpointResults"`
// Context is the final state of the context after all endpoints executed
Context map[string]interface{} `json:"-"`
// Errors contains any suite-level errors
Errors []string `json:"errors,omitempty"`
}
// AddError adds an error to the suite result
func (r *Result) AddError(err string) {
r.Errors = append(r.Errors, err)
}
// CalculateSuccess determines if the suite execution was successful
func (r *Result) CalculateSuccess() {
r.Success = true
// Check if any endpoints failed (all endpoints are required)
for _, epResult := range r.EndpointResults {
if !epResult.Success {
r.Success = false
break
}
}
// Also check for suite-level errors
if len(r.Errors) > 0 {
r.Success = false
}
}
================================================
FILE: config/suite/suite.go
================================================
package suite
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/gontext"
"github.com/TwiN/gatus/v5/config/key"
)
var (
// ErrSuiteWithNoName is the error returned when a suite has no name
ErrSuiteWithNoName = errors.New("suite must have a name")
// ErrSuiteWithNoEndpoints is the error returned when a suite has no endpoints
ErrSuiteWithNoEndpoints = errors.New("suite must have at least one endpoint")
// ErrSuiteWithDuplicateEndpointNames is the error returned when a suite has duplicate endpoint names
ErrSuiteWithDuplicateEndpointNames = errors.New("suite cannot have duplicate endpoint names")
// ErrSuiteWithInvalidTimeout is the error returned when a suite has an invalid timeout
ErrSuiteWithInvalidTimeout = errors.New("suite timeout must be positive")
// DefaultInterval is the default interval for suite execution
DefaultInterval = 10 * time.Minute
// DefaultTimeout is the default timeout for suite execution
DefaultTimeout = 5 * time.Minute
)
// Suite is a collection of endpoints that are executed sequentially with shared context
type Suite struct {
// Name of the suite. Must be unique.
Name string `yaml:"name"`
// Group the suite belongs to. Used for grouping multiple suites together.
Group string `yaml:"group,omitempty"`
// Enabled defines whether the suite is enabled
Enabled *bool `yaml:"enabled,omitempty"`
// Interval is the duration to wait between suite executions
Interval time.Duration `yaml:"interval,omitempty"`
// Timeout is the maximum duration for the entire suite execution
Timeout time.Duration `yaml:"timeout,omitempty"`
// InitialContext holds initial values that can be referenced by endpoints
InitialContext map[string]interface{} `yaml:"context,omitempty"`
// Endpoints in the suite (executed sequentially)
Endpoints []*endpoint.Endpoint `yaml:"endpoints"`
}
// IsEnabled returns whether the suite is enabled
func (s *Suite) IsEnabled() bool {
if s.Enabled == nil {
return true
}
return *s.Enabled
}
// Key returns a unique key for the suite
func (s *Suite) Key() string {
return key.ConvertGroupAndNameToKey(s.Group, s.Name)
}
// ValidateAndSetDefaults validates the suite configuration and sets default values
func (s *Suite) ValidateAndSetDefaults() error {
// Validate name
if len(s.Name) == 0 {
return ErrSuiteWithNoName
}
// Validate endpoints
if len(s.Endpoints) == 0 {
return ErrSuiteWithNoEndpoints
}
// Check for duplicate endpoint names
endpointNames := make(map[string]bool)
for _, ep := range s.Endpoints {
if endpointNames[ep.Name] {
return fmt.Errorf("%w: duplicate endpoint name '%s'", ErrSuiteWithDuplicateEndpointNames, ep.Name)
}
endpointNames[ep.Name] = true
// Suite endpoints inherit the group from the suite
ep.Group = s.Group
// Validate each endpoint
if err := ep.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid endpoint '%s': %w", ep.Name, err)
}
}
// Set default interval
if s.Interval == 0 {
s.Interval = DefaultInterval
}
// Set default timeout
if s.Timeout == 0 {
s.Timeout = DefaultTimeout
}
// Validate timeout
if s.Timeout < 0 {
return ErrSuiteWithInvalidTimeout
}
// Initialize context if nil
if s.InitialContext == nil {
s.InitialContext = make(map[string]interface{})
}
return nil
}
// Execute executes all endpoints in the suite sequentially with context sharing
func (s *Suite) Execute() *Result {
start := time.Now()
// Initialize context from suite configuration
ctx := gontext.New(s.InitialContext)
// Create suite result
result := &Result{
Name: s.Name,
Group: s.Group,
Success: true,
Timestamp: start,
EndpointResults: make([]*endpoint.Result, 0, len(s.Endpoints)),
}
// Set up timeout for the entire suite execution
timeoutChan := time.After(s.Timeout)
// Execute each endpoint sequentially
suiteHasFailed := false
for _, ep := range s.Endpoints {
// Skip non-always-run endpoints if suite has already failed
if suiteHasFailed && !ep.AlwaysRun {
continue
}
// Check timeout
select {
case <-timeoutChan:
result.AddError(fmt.Sprintf("suite execution timed out after %v", s.Timeout))
result.Success = false
break
default:
}
// Execute endpoint with context
epStartTime := time.Now()
epResult := ep.EvaluateHealthWithContext(ctx)
epDuration := time.Since(epStartTime)
// Set endpoint name, timestamp, and duration on the result
epResult.Name = ep.Name
epResult.Timestamp = epStartTime
epResult.Duration = epDuration
// Store values from the endpoint result if configured (always store, even on failure)
if ep.Store != nil {
_, err := StoreResultValues(ctx, ep.Store, epResult)
if err != nil {
epResult.AddError(fmt.Sprintf("failed to store values: %v", err))
}
}
result.EndpointResults = append(result.EndpointResults, epResult)
// Mark suite as failed on any endpoint failure
if !epResult.Success {
result.Success = false
suiteHasFailed = true
}
}
result.Context = ctx.GetAll()
result.Duration = time.Since(start)
result.CalculateSuccess()
return result
}
// StoreResultValues extracts values from an endpoint result and stores them in the gontext
func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result *endpoint.Result) (map[string]interface{}, error) {
if mappings == nil || len(mappings) == 0 {
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
}
if err := ctx.Set(contextKey, value); err != nil {
return storedValues, fmt.Errorf("failed to store %s: %w", contextKey, err)
}
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
}
// extractValueForStorage extracts a value from an endpoint result for storage in context
func extractValueForStorage(placeholder string, result *endpoint.Result) (interface{}, error) {
// Use the unified ResolvePlaceholder function (no context needed for extraction)
resolved, err := endpoint.ResolvePlaceholder(placeholder, result, nil)
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 {
return num, nil
}
// Then try float for decimals
if num, err := strconv.ParseFloat(resolved, 64); err == nil {
return num, nil
}
// Then try boolean
if boolVal, err := strconv.ParseBool(resolved); err == nil {
return boolVal, nil
}
return resolved, nil
}
================================================
FILE: config/suite/suite_status.go
================================================
package suite
// Status represents the status of a suite
type Status struct {
// Name of the suite
Name string `json:"name,omitempty"`
// Group the suite is a part of. Used for grouping multiple suites together on the front end.
Group string `json:"group,omitempty"`
// Key of the Suite
Key string `json:"key"`
// Results is the list of suite execution results
Results []*Result `json:"results"`
}
// NewStatus creates a new Status for a given Suite
func NewStatus(s *Suite) *Status {
return &Status{
Name: s.Name,
Group: s.Group,
Key: s.Key(),
Results: []*Result{},
}
}
================================================
FILE: config/suite/suite_test.go
================================================
package suite
import (
"strings"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/gontext"
)
func TestSuite_ValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
suite *Suite
wantErr bool
}{
{
name: "valid-suite",
suite: &Suite{
Name: "test-suite",
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] == 200"),
},
},
},
},
wantErr: false,
},
{
name: "suite-without-name",
suite: &Suite{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] == 200"),
},
},
},
},
wantErr: true,
},
{
name: "suite-without-endpoints",
suite: &Suite{
Name: "test-suite",
Endpoints: []*endpoint.Endpoint{},
},
wantErr: true,
},
{
name: "suite-with-duplicate-endpoint-names",
suite: &Suite{
Name: "test-suite",
Endpoints: []*endpoint.Endpoint{
{
Name: "duplicate",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] == 200"),
},
},
{
Name: "duplicate",
URL: "https://example.com",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] == 200"),
},
},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.suite.ValidateAndSetDefaults()
if (err != nil) != tt.wantErr {
t.Errorf("Suite.ValidateAndSetDefaults() error = %v, wantErr %v", err, tt.wantErr)
}
// Check defaults were set
if err == nil {
if tt.suite.Interval == 0 {
t.Errorf("Expected Interval to be set to default, got 0")
}
if tt.suite.Timeout == 0 {
t.Errorf("Expected Timeout to be set to default, got 0")
}
}
})
}
}
func TestSuite_IsEnabled(t *testing.T) {
tests := []struct {
name string
enabled *bool
want bool
}{
{
name: "nil-defaults-to-true",
enabled: nil,
want: true,
},
{
name: "explicitly-enabled",
enabled: boolPtr(true),
want: true,
},
{
name: "explicitly-disabled",
enabled: boolPtr(false),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Suite{Enabled: tt.enabled}
if got := s.IsEnabled(); got != tt.want {
t.Errorf("Suite.IsEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestSuite_Key(t *testing.T) {
tests := []struct {
name string
suite *Suite
want string
}{
{
name: "with-group",
suite: &Suite{
Name: "test-suite",
Group: "test-group",
},
want: "test-group_test-suite",
},
{
name: "without-group",
suite: &Suite{
Name: "test-suite",
Group: "",
},
want: "_test-suite",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.suite.Key(); got != tt.want {
t.Errorf("Suite.Key() = %v, want %v", got, tt.want)
}
})
}
}
func TestSuite_DefaultValues(t *testing.T) {
s := &Suite{
Name: "test",
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] == 200"),
},
},
},
}
err := s.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.Interval != DefaultInterval {
t.Errorf("Expected Interval to be %v, got %v", DefaultInterval, s.Interval)
}
if s.Timeout != DefaultTimeout {
t.Errorf("Expected Timeout to be %v, got %v", DefaultTimeout, s.Timeout)
}
if s.InitialContext == nil {
t.Error("Expected InitialContext to be initialized, got nil")
}
}
// Helper function to create bool pointers
func boolPtr(b bool) *bool {
return &b
}
func TestStoreResultValues(t *testing.T) {
ctx := gontext.New(nil)
// Create a mock result
result := &endpoint.Result{
HTTPStatus: 200,
IP: "192.168.1.1",
Duration: 100 * time.Millisecond,
Body: []byte(`{"status": "OK", "value": 42}`),
Connected: true,
}
// Define store mappings
mappings := map[string]string{
"response_code": "[STATUS]",
"server_ip": "[IP]",
"response_time": "[RESPONSE_TIME]",
"status": "[BODY].status",
"value": "[BODY].value",
"connected": "[CONNECTED]",
}
// Store values
stored, err := StoreResultValues(ctx, mappings, result)
if err != nil {
t.Fatalf("Unexpected error storing values: %v", err)
}
// Verify stored values
if stored["response_code"] != int64(200) {
t.Errorf("Expected response_code=200, got %v", stored["response_code"])
}
if stored["server_ip"] != "192.168.1.1" {
t.Errorf("Expected server_ip=192.168.1.1, got %v", stored["server_ip"])
}
if stored["status"] != "OK" {
t.Errorf("Expected status=OK, got %v", stored["status"])
}
if stored["value"] != int64(42) { // Now parsed as int64 for whole numbers
t.Errorf("Expected value=42, got %v", stored["value"])
}
if stored["connected"] != true {
t.Errorf("Expected connected=true, got %v", stored["connected"])
}
// Verify values are in context
val, err := ctx.Get("status")
if err != nil || val != "OK" {
t.Errorf("Expected status=OK in context, got %v, err=%v", val, err)
}
}
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",
Endpoints: []*endpoint.Endpoint{
{
Name: "create-resource",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] == 200"),
},
Store: map[string]string{
"created_id": "[BODY]",
},
},
{
Name: "failing-endpoint",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] != 200"), // This will fail
},
},
{
Name: "cleanup-resource",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] == 200"),
},
AlwaysRun: true,
},
},
}
if err := suite.ValidateAndSetDefaults(); err != nil {
t.Fatalf("suite validation failed: %v", err)
}
result := suite.Execute()
if result.Success {
t.Error("expected suite to fail due to middle endpoint failure")
}
if len(result.EndpointResults) != 3 {
t.Errorf("expected 3 endpoint results, got %d", len(result.EndpointResults))
}
if result.EndpointResults[0].Name != "create-resource" {
t.Errorf("expected first endpoint to be 'create-resource', got '%s'", result.EndpointResults[0].Name)
}
if result.EndpointResults[1].Name != "failing-endpoint" {
t.Errorf("expected second endpoint to be 'failing-endpoint', got '%s'", result.EndpointResults[1].Name)
}
if result.EndpointResults[1].Success {
t.Error("expected failing-endpoint to fail")
}
if result.EndpointResults[2].Name != "cleanup-resource" {
t.Errorf("expected third endpoint to be 'cleanup-resource', got '%s'", result.EndpointResults[2].Name)
}
if !result.EndpointResults[2].Success {
t.Error("expected cleanup endpoint to succeed")
}
}
func TestSuite_ExecuteWithoutAlwaysRunEndpoints(t *testing.T) {
suite := &Suite{
Name: "test-suite",
Endpoints: []*endpoint.Endpoint{
{
Name: "create-resource",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] == 200"),
},
},
{
Name: "failing-endpoint",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] != 200"), // This will fail
},
},
{
Name: "skipped-endpoint",
URL: "https://example.org",
Conditions: []endpoint.Condition{
endpoint.Condition("[STATUS] == 200"),
},
},
},
}
if err := suite.ValidateAndSetDefaults(); err != nil {
t.Fatalf("suite validation failed: %v", err)
}
result := suite.Execute()
if result.Success {
t.Error("expected suite to fail due to middle endpoint failure")
}
if len(result.EndpointResults) != 2 {
t.Errorf("expected 2 endpoint results (execution should stop after failure), got %d", len(result.EndpointResults))
}
if result.EndpointResults[0].Name != "create-resource" {
t.Errorf("expected first endpoint to be 'create-resource', got '%s'", result.EndpointResults[0].Name)
}
if result.EndpointResults[1].Name != "failing-endpoint" {
t.Errorf("expected second endpoint to be 'failing-endpoint', got '%s'", result.EndpointResults[1].Name)
}
}
func TestResult_AddError(t *testing.T) {
result := &Result{
Name: "test-suite",
Timestamp: time.Now(),
}
if len(result.Errors) != 0 {
t.Errorf("Expected 0 errors initially, got %d", len(result.Errors))
}
result.AddError("first error")
if len(result.Errors) != 1 {
t.Errorf("Expected 1 error after AddError, got %d", len(result.Errors))
}
if result.Errors[0] != "first error" {
t.Errorf("Expected 'first error', got '%s'", result.Errors[0])
}
result.AddError("second error")
if len(result.Errors) != 2 {
t.Errorf("Expected 2 errors after second AddError, got %d", len(result.Errors))
}
if result.Errors[1] != "second error" {
t.Errorf("Expected 'second error', got '%s'", result.Errors[1])
}
}
func TestResult_CalculateSuccess(t *testing.T) {
tests := []struct {
name string
endpointResults []*endpoint.Result
errors []string
expectedSuccess bool
}{
{
name: "no-endpoints-no-errors",
endpointResults: []*endpoint.Result{},
errors: []string{},
expectedSuccess: true,
},
{
name: "all-endpoints-successful-no-errors",
endpointResults: []*endpoint.Result{
{Success: true},
{Success: true},
},
errors: []string{},
expectedSuccess: true,
},
{
name: "second-endpoint-failed-no-errors",
endpointResults: []*endpoint.Result{
{Success: true},
{Success: false},
},
errors: []string{},
expectedSuccess: false,
},
{
name: "first-endpoint-failed-no-errors",
endpointResults: []*endpoint.Result{
{Success: false},
{Success: true},
},
errors: []string{},
expectedSuccess: false,
},
{
name: "all-endpoints-successful-with-errors",
endpointResults: []*endpoint.Result{
{Success: true},
{Success: true},
},
errors: []string{"suite level error"},
expectedSuccess: false,
},
{
name: "endpoint-failed-and-errors",
endpointResults: []*endpoint.Result{
{Success: true},
{Success: false},
},
errors: []string{"suite level error"},
expectedSuccess: false,
},
{
name: "no-endpoints-with-errors",
endpointResults: []*endpoint.Result{},
errors: []string{"configuration error"},
expectedSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &Result{
Name: "test-suite",
Timestamp: time.Now(),
EndpointResults: tt.endpointResults,
Errors: tt.errors,
}
result.CalculateSuccess()
if result.Success != tt.expectedSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectedSuccess, result.Success)
}
})
}
}
================================================
FILE: config/tunneling/sshtunnel/sshtunnel.go
================================================
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 := range maxRetries {
if attempt > 0 {
// Exponential backoff: 500ms, 1s, 2s
delay := baseDelay << (attempt - 1)
time.Sleep(delay)
// Close stale connection and reconnect
t.mu.Lock()
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)
}
================================================
FILE: config/tunneling/sshtunnel/sshtunnel_test.go
================================================
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)
}
}
================================================
FILE: config/tunneling/tunneling.go
================================================
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
}
================================================
FILE: config/tunneling/tunneling_test.go
================================================
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))
}
}
================================================
FILE: config/ui/ui.go
================================================
package ui
import (
"bytes"
"errors"
"html/template"
"github.com/TwiN/gatus/v5/storage"
static "github.com/TwiN/gatus/v5/web"
)
const (
defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Gatus"
defaultDashboardHeading = "Health Dashboard"
defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
defaultLogo = ""
defaultLink = ""
defaultFavicon = "/favicon.ico"
defaultFavicon16 = "/favicon-16x16.png"
defaultFavicon32 = "/favicon-32x32.png"
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
)
var (
defaultDarkMode = true
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
ErrInvalidDefaultSortBy = errors.New("invalid default-sort-by value: must be 'name', 'group', or 'health'")
ErrInvalidDefaultFilterBy = errors.New("invalid default-filter-by value: must be 'none', 'failing', or 'unstable'")
)
// Config is the configuration for the UI of Gatus
type Config struct {
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints
DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Favicon Favicon `yaml:"favicon,omitempty"` // Favourite icon to display in web browser tab or address bar
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
//////////////////////////////////////////////
// Non-configurable - used for UI rendering //
//////////////////////////////////////////////
MaximumNumberOfResults int `yaml:"-"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config
}
func (cfg *Config) IsDarkMode() bool {
if cfg.DarkMode != nil {
return *cfg.DarkMode
}
return defaultDarkMode
}
// Button is the configuration for a button on the UI
type Button struct {
Name string `yaml:"name,omitempty"` // Name is the text to display on the button
Link string `yaml:"link,omitempty"` // Link to open when the button is clicked.
}
// Validate validates the button configuration
func (btn *Button) Validate() error {
if len(btn.Name) == 0 || len(btn.Link) == 0 {
return ErrButtonValidationFailed
}
return nil
}
type Favicon struct {
Default string `yaml:"default,omitempty"` // URL or path to default favourite icon.
Size16x16 string `yaml:"size16x16,omitempty"` // URL or path to favourite icon for 16x16 size.
Size32x32 string `yaml:"size32x32,omitempty"` // URL or path to favourite icon for 32x32 size.
}
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{
Title: defaultTitle,
Description: defaultDescription,
DashboardHeading: defaultDashboardHeading,
DashboardSubheading: defaultDashboardSubheading,
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
CustomCSS: defaultCustomCSS,
DarkMode: &defaultDarkMode,
DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
Favicon: Favicon{
Default: defaultFavicon,
Size16x16: defaultFavicon16,
Size32x32: defaultFavicon32,
},
}
}
// ValidateAndSetDefaults validates the UI configuration and sets the default values if necessary.
func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Title) == 0 {
cfg.Title = defaultTitle
}
if len(cfg.Description) == 0 {
cfg.Description = defaultDescription
}
if len(cfg.DashboardHeading) == 0 {
cfg.DashboardHeading = defaultDashboardHeading
}
if len(cfg.DashboardSubheading) == 0 {
cfg.DashboardSubheading = defaultDashboardSubheading
}
if len(cfg.Header) == 0 {
cfg.Header = defaultHeader
}
if len(cfg.Logo) == 0 {
cfg.Logo = defaultLogo
}
if len(cfg.Link) == 0 {
cfg.Link = defaultLink
}
if len(cfg.CustomCSS) == 0 {
cfg.CustomCSS = defaultCustomCSS
}
if cfg.DarkMode == nil {
cfg.DarkMode = &defaultDarkMode
}
if len(cfg.DefaultSortBy) == 0 {
cfg.DefaultSortBy = defaultSortBy
} else if cfg.DefaultSortBy != "name" && cfg.DefaultSortBy != "group" && cfg.DefaultSortBy != "health" {
return ErrInvalidDefaultSortBy
}
if len(cfg.DefaultFilterBy) == 0 {
cfg.DefaultFilterBy = defaultFilterBy
} else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
return ErrInvalidDefaultFilterBy
}
if len(cfg.Favicon.Default) == 0 {
cfg.Favicon.Default = defaultFavicon
}
if len(cfg.Favicon.Size16x16) == 0 {
cfg.Favicon.Size16x16 = defaultFavicon16
}
if len(cfg.Favicon.Size32x32) == 0 {
cfg.Favicon.Size32x32 = defaultFavicon32
}
for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil {
return err
}
}
// Validate that the template works
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil {
return err
}
var buffer bytes.Buffer
return t.Execute(&buffer, ViewData{UI: cfg, Theme: "dark"})
}
type ViewData struct {
UI *Config
Theme string
}
================================================
FILE: config/ui/ui_test.go
================================================
package ui
import (
"errors"
"strconv"
"testing"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
t.Run("empty-config", func(t *testing.T) {
cfg := &Config{
Title: "",
Description: "",
DashboardHeading: "",
DashboardSubheading: "",
Header: "",
Logo: "",
Link: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != defaultTitle {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
}
if cfg.DashboardHeading != defaultDashboardHeading {
t.Errorf("expected DashboardHeading to be %s, got %s", defaultDashboardHeading, cfg.DashboardHeading)
}
if cfg.DashboardSubheading != defaultDashboardSubheading {
t.Errorf("expected DashboardSubheading to be %s, got %s", defaultDashboardSubheading, cfg.DashboardSubheading)
}
if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
}
if cfg.DefaultSortBy != defaultSortBy {
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != defaultFilterBy {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
}
if cfg.Favicon.Default != defaultFavicon {
t.Errorf("expected favicon to be %s, got %s", defaultFavicon, cfg.Favicon.Default)
}
if cfg.Favicon.Size16x16 != defaultFavicon16 {
t.Errorf("expected favicon to be %s, got %s", defaultFavicon16, cfg.Favicon.Size16x16)
}
if cfg.Favicon.Size32x32 != defaultFavicon32 {
t.Errorf("expected favicon to be %s, got %s", defaultFavicon32, cfg.Favicon.Size32x32)
}
})
t.Run("custom-values", func(t *testing.T) {
cfg := &Config{
Title: "Custom Title",
Description: "Custom Description",
DashboardHeading: "Production Status",
DashboardSubheading: "Monitor all production endpoints",
Header: "My Company",
Logo: "https://example.com/logo.png",
Link: "https://example.com",
DefaultSortBy: "health",
DefaultFilterBy: "failing",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != "Custom Title" {
t.Errorf("expected title to be preserved, got %s", cfg.Title)
}
if cfg.Description != "Custom Description" {
t.Errorf("expected description to be preserved, got %s", cfg.Description)
}
if cfg.DashboardHeading != "Production Status" {
t.Errorf("expected DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
}
if cfg.DashboardSubheading != "Monitor all production endpoints" {
t.Errorf("expected DashboardSubheading to be preserved, got %s", cfg.DashboardSubheading)
}
if cfg.Header != "My Company" {
t.Errorf("expected header to be preserved, got %s", cfg.Header)
}
if cfg.Logo != "https://example.com/logo.png" {
t.Errorf("expected logo to be preserved, got %s", cfg.Logo)
}
if cfg.Link != "https://example.com" {
t.Errorf("expected link to be preserved, got %s", cfg.Link)
}
if cfg.DefaultSortBy != "health" {
t.Errorf("expected defaultSortBy to be preserved, got %s", cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != "failing" {
t.Errorf("expected defaultFilterBy to be preserved, got %s", cfg.DefaultFilterBy)
}
})
t.Run("partial-custom-values", func(t *testing.T) {
cfg := &Config{
Title: "Custom Title",
DashboardHeading: "My Dashboard",
Header: "",
DashboardSubheading: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != "Custom Title" {
t.Errorf("expected custom title to be preserved, got %s", cfg.Title)
}
if cfg.DashboardHeading != "My Dashboard" {
t.Errorf("expected custom DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
}
if cfg.DashboardSubheading != defaultDashboardSubheading {
t.Errorf("expected DashboardSubheading to use default, got %s", cfg.DashboardSubheading)
}
if cfg.Header != defaultHeader {
t.Errorf("expected header to use default, got %s", cfg.Header)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to use default, got %s", cfg.Description)
}
})
}
func TestButton_Validate(t *testing.T) {
scenarios := []struct {
Name, Link string
ExpectedError error
}{
{
Name: "",
Link: "",
ExpectedError: ErrButtonValidationFailed,
},
{
Name: "",
Link: "link",
ExpectedError: ErrButtonValidationFailed,
},
{
Name: "name",
Link: "",
ExpectedError: ErrButtonValidationFailed,
},
{
Name: "name",
Link: "link",
ExpectedError: nil,
},
}
for i, scenario := range scenarios {
t.Run(strconv.Itoa(i)+"_"+scenario.Name+"_"+scenario.Link, func(t *testing.T) {
button := &Button{
Name: scenario.Name,
Link: scenario.Link,
}
if err := button.Validate(); err != scenario.ExpectedError {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestGetDefaultConfig(t *testing.T) {
defaultConfig := GetDefaultConfig()
if defaultConfig.Title != defaultTitle {
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
}
if defaultConfig.DashboardHeading != defaultDashboardHeading {
t.Error("expected GetDefaultConfig() to return defaultDashboardHeading, got", defaultConfig.DashboardHeading)
}
if defaultConfig.DashboardSubheading != defaultDashboardSubheading {
t.Error("expected GetDefaultConfig() to return defaultDashboardSubheading, got", defaultConfig.DashboardSubheading)
}
if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
}
if defaultConfig.DefaultSortBy != defaultSortBy {
t.Error("expected GetDefaultConfig() to return defaultSortBy, got", defaultConfig.DefaultSortBy)
}
if defaultConfig.DefaultFilterBy != defaultFilterBy {
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
}
}
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultSortBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultSortBy",
DefaultSortBy: "",
ExpectedError: nil,
ExpectedValue: defaultSortBy,
},
{
Name: "ValidDefaultSortBy_name",
DefaultSortBy: "name",
ExpectedError: nil,
ExpectedValue: "name",
},
{
Name: "ValidDefaultSortBy_group",
DefaultSortBy: "group",
ExpectedError: nil,
ExpectedValue: "group",
},
{
Name: "ValidDefaultSortBy_health",
DefaultSortBy: "health",
ExpectedError: nil,
ExpectedValue: "health",
},
{
Name: "InvalidDefaultSortBy",
DefaultSortBy: "invalid",
ExpectedError: ErrInvalidDefaultSortBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultSortBy: scenario.DefaultSortBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultSortBy != scenario.ExpectedValue {
t.Errorf("expected DefaultSortBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultSortBy)
}
})
}
}
func TestConfig_ValidateAndSetDefaults_DefaultFilterBy(t *testing.T) {
scenarios := []struct {
Name string
DefaultFilterBy string
ExpectedError error
ExpectedValue string
}{
{
Name: "EmptyDefaultFilterBy",
DefaultFilterBy: "",
ExpectedError: nil,
ExpectedValue: defaultFilterBy,
},
{
Name: "ValidDefaultFilterBy_none",
DefaultFilterBy: "none",
ExpectedError: nil,
ExpectedValue: "none",
},
{
Name: "ValidDefaultFilterBy_failing",
DefaultFilterBy: "failing",
ExpectedError: nil,
ExpectedValue: "failing",
},
{
Name: "ValidDefaultFilterBy_unstable",
DefaultFilterBy: "unstable",
ExpectedError: nil,
ExpectedValue: "unstable",
},
{
Name: "InvalidDefaultFilterBy",
DefaultFilterBy: "invalid",
ExpectedError: ErrInvalidDefaultFilterBy,
ExpectedValue: "invalid",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg := &Config{DefaultFilterBy: scenario.DefaultFilterBy}
err := cfg.ValidateAndSetDefaults()
if !errors.Is(err, scenario.ExpectedError) {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
if cfg.DefaultFilterBy != scenario.ExpectedValue {
t.Errorf("expected DefaultFilterBy to be %s, got %s", scenario.ExpectedValue, cfg.DefaultFilterBy)
}
})
}
}
================================================
FILE: config/util.go
================================================
package config
// toPtr returns a pointer to the given value
func toPtr[T any](value T) *T {
return &value
}
================================================
FILE: config/web/web.go
================================================
package web
import (
"crypto/tls"
"errors"
"fmt"
"math"
)
const (
// DefaultAddress is the default address the application will bind to
DefaultAddress = "0.0.0.0"
// DefaultPort is the default port the application will listen on
DefaultPort = 8080
// DefaultReadBufferSize is the default value for ReadBufferSize
DefaultReadBufferSize = 8192
// MinimumReadBufferSize is the minimum value for ReadBufferSize, and also the default value set
// for fiber.Config.ReadBufferSize
MinimumReadBufferSize = 4096
)
// Config is the structure which supports the configuration of the server listening to requests
type Config struct {
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
Address string `yaml:"address"`
// Port to listen on (default to 8080 specified by DefaultPort)
Port int `yaml:"port"`
// ReadBufferSize sets fiber.Config.ReadBufferSize, which is the buffer size for reading requests coming from a
// single connection and also acts as a limit for the maximum header size.
//
// If you're getting occasional "Request Header Fields Too Large", you may want to try increasing this value.
//
// Defaults to DefaultReadBufferSize
ReadBufferSize int `yaml:"read-buffer-size,omitempty"`
// TLS configuration (optional)
TLS *TLSConfig `yaml:"tls,omitempty"`
}
type TLSConfig struct {
// CertificateFile is the public certificate for TLS in PEM format.
CertificateFile string `yaml:"certificate-file,omitempty"`
// PrivateKeyFile is the private key file for TLS in PEM format.
PrivateKeyFile string `yaml:"private-key-file,omitempty"`
}
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{
Address: DefaultAddress,
Port: DefaultPort,
ReadBufferSize: DefaultReadBufferSize,
}
}
// ValidateAndSetDefaults validates the web configuration and sets the default values if necessary.
func (web *Config) ValidateAndSetDefaults() error {
// Validate the Address
if len(web.Address) == 0 {
web.Address = DefaultAddress
}
// Validate the Port
if web.Port == 0 {
web.Port = DefaultPort
} else if web.Port < 0 || web.Port > math.MaxUint16 {
return fmt.Errorf("invalid port: value should be between %d and %d", 0, math.MaxUint16)
}
// Validate ReadBufferSize
if web.ReadBufferSize == 0 {
web.ReadBufferSize = DefaultReadBufferSize // Not set? Use the default value.
} else if web.ReadBufferSize < MinimumReadBufferSize {
web.ReadBufferSize = MinimumReadBufferSize // Below the minimum? Use the minimum value.
}
// Try to load the TLS certificates
if web.TLS != nil {
if err := web.TLS.isValid(); err != nil {
return fmt.Errorf("invalid tls config: %w", err)
}
}
return nil
}
func (web *Config) HasTLS() bool {
return web.TLS != nil && len(web.TLS.CertificateFile) > 0 && len(web.TLS.PrivateKeyFile) > 0
}
// SocketAddress returns the combination of the Address and the Port
func (web *Config) SocketAddress() string {
return fmt.Sprintf("%s:%d", web.Address, web.Port)
}
func (t *TLSConfig) isValid() error {
if len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 {
_, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile)
if err != nil {
return err
}
return nil
}
return errors.New("certificate-file and private-key-file must be specified")
}
================================================
FILE: config/web/web_test.go
================================================
package web
import (
"testing"
)
func TestGetDefaultConfig(t *testing.T) {
defaultConfig := GetDefaultConfig()
if defaultConfig.Port != DefaultPort {
t.Error("expected default config to have the default port")
}
if defaultConfig.Address != DefaultAddress {
t.Error("expected default config to have the default address")
}
if defaultConfig.ReadBufferSize != DefaultReadBufferSize {
t.Error("expected default config to have the default read buffer size")
}
if defaultConfig.TLS != nil {
t.Error("expected default config to have TLS disabled")
}
}
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
scenarios := []struct {
name string
cfg *Config
expectedAddress string
expectedPort int
expectedReadBufferSize int
expectedErr bool
}{
{
name: "no-explicit-config",
cfg: &Config{},
expectedAddress: "0.0.0.0",
expectedPort: 8080,
expectedReadBufferSize: 8192,
expectedErr: false,
},
{
name: "invalid-port",
cfg: &Config{Port: 100000000},
expectedErr: true,
},
{
name: "read-buffer-size-below-minimum",
cfg: &Config{ReadBufferSize: 1024},
expectedAddress: "0.0.0.0",
expectedPort: 8080,
expectedReadBufferSize: MinimumReadBufferSize, // minimum is 4096, default is 8192.
expectedErr: false,
},
{
name: "read-buffer-size-at-minimum",
cfg: &Config{ReadBufferSize: MinimumReadBufferSize},
expectedAddress: "0.0.0.0",
expectedPort: 8080,
expectedReadBufferSize: 4096,
expectedErr: false,
},
{
name: "custom-read-buffer-size",
cfg: &Config{ReadBufferSize: 65536},
expectedAddress: "0.0.0.0",
expectedPort: 8080,
expectedReadBufferSize: 65536,
expectedErr: false,
},
{
name: "with-good-tls-config",
cfg: &Config{Port: 443, TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
expectedAddress: "0.0.0.0",
expectedPort: 443,
expectedReadBufferSize: 8192,
expectedErr: false,
},
{
name: "with-bad-tls-config",
cfg: &Config{Port: 443, TLS: &TLSConfig{CertificateFile: "../../testdata/badcert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
expectedAddress: "0.0.0.0",
expectedPort: 443,
expectedReadBufferSize: 8192,
expectedErr: true,
},
{
name: "with-partial-tls-config",
cfg: &Config{Port: 443, TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../../testdata/cert.key"}},
expectedAddress: "0.0.0.0",
expectedPort: 443,
expectedReadBufferSize: 8192,
expectedErr: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if (err != nil) != scenario.expectedErr {
t.Errorf("expected the existence of an error to be %v, got %v", scenario.expectedErr, err)
return
}
if !scenario.expectedErr {
if scenario.cfg.Port != scenario.expectedPort {
t.Errorf("expected Port to be %d, got %d", scenario.expectedPort, scenario.cfg.Port)
}
if scenario.cfg.ReadBufferSize != scenario.expectedReadBufferSize {
t.Errorf("expected ReadBufferSize to be %d, got %d", scenario.expectedReadBufferSize, scenario.cfg.ReadBufferSize)
}
if scenario.cfg.Address != scenario.expectedAddress {
t.Errorf("expected Address to be %s, got %s", scenario.expectedAddress, scenario.cfg.Address)
}
}
})
}
}
func TestConfig_SocketAddress(t *testing.T) {
web := &Config{
Address: "0.0.0.0",
Port: 8081,
}
if web.SocketAddress() != "0.0.0.0:8081" {
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
}
}
func TestConfig_isValid(t *testing.T) {
scenarios := []struct {
name string
cfg *Config
expectedErr bool
}{
{
name: "good-tls-config",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
expectedErr: false,
},
{
name: "missing-certificate-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "doesnotexist", PrivateKeyFile: "../../testdata/cert.key"}},
expectedErr: true,
},
{
name: "bad-certificate-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/badcert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
expectedErr: true,
},
{
name: "no-certificate-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../../testdata/cert.key"}},
expectedErr: true,
},
{
name: "missing-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: "doesnotexist"}},
expectedErr: true,
},
{
name: "no-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: ""}},
expectedErr: true,
},
{
name: "bad-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: "../../testdata/badcert.key"}},
expectedErr: true,
},
{
name: "bad-certificate-and-private-key-file",
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/badcert.pem", PrivateKeyFile: "../../testdata/badcert.key"}},
expectedErr: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.cfg.ValidateAndSetDefaults()
if (err != nil) != scenario.expectedErr {
t.Errorf("expected the existence of an error to be %v, got %v", scenario.expectedErr, err)
return
}
if !scenario.expectedErr {
if scenario.cfg.TLS.isValid() != nil {
t.Error("cfg.TLS.isValid() returned an error even though no error was expected")
}
}
})
}
}
================================================
FILE: controller/controller.go
================================================
package controller
import (
"os"
"time"
"github.com/TwiN/gatus/v5/api"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
)
var (
app *fiber.App
)
// Handle creates the router and starts the server
func Handle(cfg *config.Config) {
api := api.New(cfg)
app = api.Router()
server := app.Server()
server.ReadTimeout = 15 * time.Second
server.WriteTimeout = 15 * time.Second
server.IdleTimeout = 15 * time.Second
if os.Getenv("ROUTER_TEST") == "true" {
return
}
logr.Info("[controller.Handle] Listening on " + cfg.Web.SocketAddress())
if cfg.Web.HasTLS() {
err := app.ListenTLS(cfg.Web.SocketAddress(), cfg.Web.TLS.CertificateFile, cfg.Web.TLS.PrivateKeyFile)
if err != nil {
logr.Fatalf("[controller.Handle] %s", err.Error())
}
} else {
err := app.Listen(cfg.Web.SocketAddress())
if err != nil {
logr.Fatalf("[controller.Handle] %s", err.Error())
}
}
logr.Info("[controller.Handle] Server has shut down successfully")
}
// Shutdown stops the server
func Shutdown() {
if app != nil {
_ = app.Shutdown()
app = nil
}
}
================================================
FILE: controller/controller_test.go
================================================
package controller
import (
"math/rand"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/web"
"github.com/gofiber/fiber/v2"
)
func TestHandle(t *testing.T) {
cfg := &config.Config{
Web: &web.Config{
Address: "0.0.0.0",
Port: rand.Intn(65534),
},
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
_ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv()
Handle(cfg)
defer Shutdown()
request := httptest.NewRequest("GET", "/health", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != 200 {
t.Error("expected GET /health to return status code 200")
}
if app == nil {
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
}
}
func TestHandleTLS(t *testing.T) {
scenarios := []struct {
name string
tls *web.TLSConfig
expectedStatusCode int
}{
{
name: "good-tls-config",
tls: &web.TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
expectedStatusCode: 200,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
cfg := &config.Config{
Web: &web.Config{Address: "0.0.0.0", Port: rand.Intn(65534), TLS: scenario.tls},
Endpoints: []*endpoint.Endpoint{
{Name: "frontend", Group: "core"},
{Name: "backend", Group: "core"},
},
}
if err := cfg.Web.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error from web (TLS) validation, got", err)
}
_ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv()
Handle(cfg)
defer Shutdown()
request := httptest.NewRequest("GET", "/health", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != scenario.expectedStatusCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.expectedStatusCode, response.StatusCode)
}
if app == nil {
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
}
})
}
}
func TestShutdown(t *testing.T) {
// Pretend that we called controller.Handle(), which initializes the server variable
app = fiber.New()
Shutdown()
if app != nil {
t.Error("server should've been shut down")
}
}
================================================
FILE: docs/pagerduty-integration-guide.md
================================================
# PagerDuty + Gatus Integration Benefits
- Notify on-call responders based on alerts sent from Gatus.
- Incidents will automatically resolve in PagerDuty when the endpoint that caused the incident in Gatus returns to a healthy state.
# How it Works
- Endpoints that do not meet the user-specified conditions and that are configured with alerts of type `pagerduty` will trigger a new incident on the corresponding PagerDuty service when the alert's defined `failure-threshold` has been reached.
- Once the unhealthy endpoints have returned to a healthy state for the number of executions defined in `success-threshold`, the previously triggered incident will be automatically resolved.
# Requirements
- PagerDuty integrations require an Admin base role for account authorization. If you do not have this role, please reach out to an Admin or Account Owner within your organization to configure the integration.
# Support
If you need help with this integration, please create an issue at https://github.com/TwiN/gatus/issues
# Integration Walkthrough
## In PagerDuty
### Integrating With a PagerDuty Service
1. From the **Configuration** menu, select **Services**.
2. There are two ways to add an integration to a service:
* **If you are adding your integration to an existing service**: Click the **name** of the service you want to add the integration to. Then, select the **Integrations** tab and click the **New Integration** button.
* **If you are creating a new service for your integration**: Please read our documentation in section [Configuring Services and Integrations](https://support.pagerduty.com/docs/services-and-integrations#section-configuring-services-and-integrations) and follow the steps outlined in the [Create a New Service](https://support.pagerduty.com/docs/services-and-integrations#section-create-a-new-service) section, selecting **Gatus** as the **Integration Type** in step 4. Continue with the In Gatus section (below) once you have finished these steps.
3. Enter an **Integration Name** in the format `gatus-service-name` (e.g. `Gatus-Shopping-Cart`) and select **Gatus** from the Integration Type menu.
4. Click the **Add Integration** button to save your new integration. You will be redirected to the Integrations tab for your service.
5. An **Integration Key** will be generated on this screen. Keep this key saved in a safe place, as it will be used when you configure the integration with **Gatus** in the next section.

## In Gatus
In your configuration file, you must first specify the integration key at `alerting.pagerduty.integration-key`, like so:
```yaml
alerting:
pagerduty:
integration-key: "********************************"
```
You can now add alerts of type `pagerduty` in the endpoint you've defined, like so:
```yaml
endpoints:
- name: website
interval: 30s
url: "https://twin.sh/health"
alerts:
- type: pagerduty
enabled: true
failure-threshold: 3
success-threshold: 5
description: "healthcheck failed 3 times in a row"
send-on-resolved: true
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
```
The sample above will do the following:
- Send a request to the `https://twin.sh/health` (`endpoints[].url`) specified every **30s** (`endpoints[].interval`)
- Evaluate the conditions to determine whether the endpoint is "healthy" or not
- **If all conditions are not met 3 (`endpoints[].alerts[].failure-threshold`) times in a row**: Gatus will create a new incident
- **If, after an incident has been triggered, all conditions are met 5 (`endpoints[].alerts[].success-threshold`) times in a row _AND_ `endpoints[].alerts[].send-on-resolved` is set to `true`**: Gatus will resolve the triggered incident
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to true for alerts of type `pagerduty`.
# How to Uninstall
1. Navigate to the PagerDuty service you'd like to uninstall the Gatus integration from
2. Click on the **Integration** tab
3. Click on the **Gatus** integration
4. Click on **Delete Integration**
While the above will prevent incidents from being created, you are also highly encouraged to disable the alerts
in your Gatus configuration files or simply remove the integration key from the configuration file.
================================================
FILE: go.mod
================================================
module github.com/TwiN/gatus/v5
go 1.25.5
require (
code.gitea.io/sdk/gitea v0.23.2
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.3.0
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.7
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18
github.com/coreos/go-oidc/v3 v3.17.0
github.com/gofiber/fiber/v2 v2.52.11
github.com/google/go-github/v48 v48.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2
github.com/lib/pq v1.11.1
github.com/miekg/dns v1.1.72
github.com/prometheus-community/pro-bing v0.8.0
github.com/prometheus/client_golang v1.23.2
github.com/registrobr/rdap v1.1.8
github.com/valyala/fasthttp v1.69.0
github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.47.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.19.0
google.golang.org/api v0.265.0
google.golang.org/grpc v1.78.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
)
require (
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // 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.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.4.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.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.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // 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/valyala/bytebufferpool v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/image v0.35.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
================================================
FILE: go.sum
================================================
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
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.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/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=
github.com/TwiN/g8/v2 v2.0.0/go.mod h1:4sVAF27q8T8ISggRa/Fb0drw7wpB22B6eWd+/+SGMqE=
github.com/TwiN/gocache/v2 v2.4.0 h1:BZ/TqvhipDQE23MFFTjC0MiI1qZ7GEVtSdOFVVXyr18=
github.com/TwiN/gocache/v2 v2.4.0/go.mod h1:Cl1c0qNlQlXzJhTpAARVqpQDSuGDM5RhtzPYAM1x17g=
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.3.0 h1:V2+IUh5OGim8F3axTOMSlVmxNRaBrgpyiv5U0Dvbt5I=
github.com/TwiN/whois v1.3.0/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.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-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18 h1:2Lnd3ZNTyWpFJJM55y0mP0aESovm+vFuFEwLijucUL8=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18/go.mod h1:BLwHw6wdkA6NfnW/cFaVcvpwdIXHLAkpe6nsLF9BVww=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.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=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
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.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.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.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/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=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE=
github.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2 h1:36qep4gxKs+JgeHGWeQ040RyZdt9kQlLglL1rFVn/oQ=
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
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.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=
github.com/registrobr/rdap v1.1.8/go.mod h1:VY2DVrpsJpUfy9gj2QvurGymCgZV11/11cxQz5CxO+w=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
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=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.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=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.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=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
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=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=
google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
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.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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
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/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
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=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
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=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
================================================
FILE: jsonpath/jsonpath.go
================================================
package jsonpath
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// Eval is a half-baked json path implementation that needs some love
func Eval(path string, b []byte) (string, int, error) {
if len(path) == 0 && !(len(b) != 0 && b[0] == '[' && b[len(b)-1] == ']') {
// if there's no path AND the value is not a JSON array, then there's nothing to walk
return string(b), len(b), nil
}
var object interface{}
if err := json.Unmarshal(b, &object); err != nil {
return "", 0, err
}
return walk(path, object)
}
// walk traverses the object and returns the value as a string as well as its length
func walk(path string, object interface{}) (string, int, error) {
var keys []string
startOfCurrentKey, bracketDepth := 0, 0
for i := range path {
if path[i] == '[' {
bracketDepth++
} else if path[i] == ']' {
bracketDepth--
}
// If we encounter a dot, we've reached the end of a key unless we're inside a bracket
if path[i] == '.' && bracketDepth == 0 {
keys = append(keys, path[startOfCurrentKey:i])
startOfCurrentKey = i + 1
}
}
if startOfCurrentKey <= len(path) {
keys = append(keys, path[startOfCurrentKey:])
}
currentKey := keys[0]
switch value := extractValue(currentKey, object).(type) {
case map[string]interface{}:
newPath := strings.Replace(path, fmt.Sprintf("%s.", currentKey), "", 1)
if path == newPath {
// If the path hasn't changed, it means we're at the end of the path
// So we'll treat it as a string by re-marshaling it to JSON since it's a map.
// Note that the output JSON will be minified.
b, err := json.Marshal(value)
return string(b), len(b), err
}
return walk(newPath, value)
case string:
if len(keys) > 1 {
return "", 0, fmt.Errorf("couldn't walk through '%s', because '%s' was a string instead of an object", keys[1], currentKey)
}
return value, len(value), nil
case []interface{}:
return fmt.Sprintf("%v", value), len(value), nil
case interface{}:
newValue := fmt.Sprintf("%v", value)
return newValue, len(newValue), nil
default:
return "", 0, fmt.Errorf("couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'", currentKey, value)
}
}
func extractValue(currentKey string, value interface{}) interface{} {
// Check if the current key ends with [#]
if strings.HasSuffix(currentKey, "]") && strings.Contains(currentKey, "[") {
var isNestedArray bool
var index string
startOfBracket, endOfBracket, bracketDepth := 0, 0, 0
for i := range currentKey {
if currentKey[i] == '[' {
startOfBracket = i
bracketDepth++
} else if currentKey[i] == ']' && bracketDepth == 1 {
bracketDepth--
endOfBracket = i
index = currentKey[startOfBracket+1 : i]
if len(currentKey) > i+1 && currentKey[i+1] == '[' {
isNestedArray = true // there's more keys.
}
break
}
}
arrayIndex, err := strconv.Atoi(index)
if err != nil {
return nil
}
currentKeyWithoutIndex := currentKey[:startOfBracket]
// if currentKeyWithoutIndex contains only an index (i.e. [0] or 0)
if len(currentKeyWithoutIndex) == 0 {
array, _ := value.([]interface{})
if len(array) > arrayIndex {
if isNestedArray {
return extractValue(currentKey[endOfBracket+1:], array[arrayIndex])
}
return array[arrayIndex]
}
return nil
}
if value == nil || value.(map[string]interface{})[currentKeyWithoutIndex] == nil {
return nil
}
// if currentKeyWithoutIndex contains both a key and an index (i.e. data[0])
array, _ := value.(map[string]interface{})[currentKeyWithoutIndex].([]interface{})
if len(array) > arrayIndex {
if isNestedArray {
return extractValue(currentKey[endOfBracket+1:], array[arrayIndex])
}
return array[arrayIndex]
}
return nil
}
if valueAsSlice, ok := value.([]interface{}); ok {
// If the type is a slice, return it
// This happens when the body (value) is a JSON array
return valueAsSlice
}
if valueAsMap, ok := value.(map[string]interface{}); ok {
// If the value is a map, then we get the currentKey from that map
// This happens when the body (value) is a JSON object
return valueAsMap[currentKey]
}
// If the value is neither a map, nor a slice, nor an index, then we cannot retrieve the currentKey
// from said value. This usually happens when the body (value) is null.
return value
}
================================================
FILE: jsonpath/jsonpath_bench_test.go
================================================
package jsonpath
import "testing"
func BenchmarkEval(b *testing.B) {
for i := 0; i < b.N; i++ {
Eval("ids[0]", []byte(`{"ids": [1, 2]}`))
Eval("long.simple.walk", []byte(`{"long": {"simple": {"walk": "value"}}}`))
Eval("data[0].apps[1].name", []byte(`{"data": [{"apps": [{"name":"app1"}, {"name":"app2"}, {"name":"app3"}]}]}`))
}
}
================================================
FILE: jsonpath/jsonpath_test.go
================================================
package jsonpath
import (
"testing"
)
func TestEval(t *testing.T) {
type Scenario struct {
Name string
Path string
Data string
ExpectedOutput string
ExpectedOutputLength int
ExpectedError bool
}
scenarios := []Scenario{
{
Name: "simple",
Path: "key",
Data: `{"key": "value"}`,
ExpectedOutput: "value",
ExpectedOutputLength: 5,
ExpectedError: false,
},
{
Name: "simple-with-invalid-data",
Path: "key",
Data: "invalid data",
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "invalid-path",
Path: "key",
Data: `{}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "long-simple-walk",
Path: "long.simple.walk",
Data: `{"long": {"simple": {"walk": "value"}}}`,
ExpectedOutput: "value",
ExpectedOutputLength: 5,
ExpectedError: false,
},
{
Name: "array-of-objects",
Path: "ids[1].id",
Data: `{"ids": [{"id": 1}, {"id": 2}]}`,
ExpectedOutput: "2",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "array-of-values",
Path: "ids[0]",
Data: `{"ids": [1, 2]}`,
ExpectedOutput: "1",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "array-of-values-with-no-path",
Path: "",
Data: `[1, 2]`,
ExpectedOutput: "[1 2]", // the output is an array
ExpectedOutputLength: 2,
ExpectedError: false,
},
{
Name: "array-of-values-and-invalid-index",
Path: "ids[wat]",
Data: `{"ids": [1, 2]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "array-of-values-at-root",
Path: "[1]",
Data: `[1, 2]`,
ExpectedOutput: "2",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "array-of-objects-at-root",
Path: "[0]",
Data: `[{"id": 1}, {"id": 2}]`,
ExpectedOutput: `{"id":1}`,
ExpectedOutputLength: 8,
ExpectedError: false,
},
{
Name: "array-of-objects-with-int-at-root",
Path: "[0].id",
Data: `[{"id": 1}, {"id": 2}]`,
ExpectedOutput: "1",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "array-of-objects-at-root-and-invalid-index",
Path: "[5].id",
Data: `[{"id": 1}, {"id": 2}]`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "long-walk-and-array",
Path: "data.ids[0].id",
Data: `{"data": {"ids": [{"id": 1}, {"id": 2}, {"id": 3}]}}`,
ExpectedOutput: "1",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "nested-array",
Path: "[3][2]",
Data: `[[1, 2], [3, 4], [], [5, 6, 7]]`,
ExpectedOutput: "7",
ExpectedOutputLength: 1,
ExpectedError: false,
},
{
Name: "object-with-nested-arrays",
Path: "data[1][1]",
Data: `{"data": [["a", "b", "c"], ["d", "eeeee", "f"]]}`,
ExpectedOutput: "eeeee",
ExpectedOutputLength: 5,
ExpectedError: false,
},
{
Name: "object-with-arrays-of-objects",
Path: "data[0].apps[1].name",
Data: `{"data": [{"apps": [{"name":"app1"}, {"name":"app2"}, {"name":"app3"}]}]}`,
ExpectedOutput: "app2",
ExpectedOutputLength: 4,
ExpectedError: false,
},
{
Name: "object-with-arrays-of-objects-with-missing-element",
Path: "data[0].apps[1].name",
Data: `{"data": [{"apps": []}]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "partially-invalid-path-issue122",
Path: "data.name.invalid",
Data: `{"data": {"name": "john"}}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "float-as-string",
Path: "balance",
Data: `{"balance": "123.40000000000005"}`,
ExpectedOutput: "123.40000000000005",
ExpectedOutputLength: 18,
ExpectedError: false,
},
{
Name: "float-as-number",
Path: "balance",
Data: `{"balance": 123.40000000000005}`,
ExpectedOutput: "123.40000000000005",
ExpectedOutputLength: 18,
ExpectedError: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
output, outputLength, err := Eval(scenario.Path, []byte(scenario.Data))
if (err != nil) != scenario.ExpectedError {
if scenario.ExpectedError {
t.Errorf("Expected error, got '%v'", err)
} else {
t.Errorf("Expected no error, got '%v'", err)
}
}
if outputLength != scenario.ExpectedOutputLength {
t.Errorf("Expected output length to be %v, but was %v", scenario.ExpectedOutputLength, outputLength)
}
if output != scenario.ExpectedOutput {
t.Errorf("Expected output to be %v, but was %v", scenario.ExpectedOutput, output)
}
})
}
}
================================================
FILE: main.go
================================================
package main
import (
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/controller"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
"github.com/TwiN/logr"
)
const (
GatusConfigPathEnvVar = "GATUS_CONFIG_PATH"
GatusConfigFileEnvVar = "GATUS_CONFIG_FILE" // Deprecated in favor of GatusConfigPathEnvVar
GatusLogLevelEnvVar = "GATUS_LOG_LEVEL"
)
func main() {
if delayInSeconds, _ := strconv.Atoi(os.Getenv("GATUS_DELAY_START_SECONDS")); delayInSeconds > 0 {
logr.Infof("Delaying start by %d seconds", delayInSeconds)
time.Sleep(time.Duration(delayInSeconds) * time.Second)
}
configureLogging()
cfg, err := loadConfiguration()
if err != nil {
panic(err)
}
initializeStorage(cfg)
start(cfg)
// Wait for termination signal
signalChannel := make(chan os.Signal, 1)
done := make(chan bool, 1)
signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM)
go func() {
<-signalChannel
logr.Info("Received termination signal, attempting to gracefully shut down")
stop(cfg)
save()
done <- true
}()
<-done
logr.Info("Shutting down")
}
func start(cfg *config.Config) {
go controller.Handle(cfg)
metrics.InitializePrometheusMetrics(cfg, nil)
watchdog.Monitor(cfg)
go listenToConfigurationFileChanges(cfg)
}
func stop(cfg *config.Config) {
watchdog.Shutdown(cfg)
controller.Shutdown()
metrics.UnregisterPrometheusMetrics()
closeTunnels(cfg)
}
func save() {
if err := store.Get().Save(); err != nil {
logr.Errorf("Failed to save storage provider: %s", err.Error())
}
}
func configureLogging() {
logLevelAsString := os.Getenv(GatusLogLevelEnvVar)
if logLevel, err := logr.LevelFromString(logLevelAsString); err != nil {
logr.SetThreshold(logr.LevelInfo)
if len(logLevelAsString) == 0 {
logr.Infof("[main.configureLogging] Defaulting log level to %s", logr.LevelInfo)
} else {
logr.Warnf("[main.configureLogging] Invalid log level '%s', defaulting to %s", logLevelAsString, logr.LevelInfo)
}
} else {
logr.SetThreshold(logLevel)
logr.Infof("[main.configureLogging] Log Level is set to %s", logr.GetThreshold())
}
}
func loadConfiguration() (*config.Config, error) {
configPath := os.Getenv(GatusConfigPathEnvVar)
// Backwards compatibility
if len(configPath) == 0 {
if configPath = os.Getenv(GatusConfigFileEnvVar); len(configPath) > 0 {
logr.Warnf("WARNING: %s is deprecated. Please use %s instead.", GatusConfigFileEnvVar, GatusConfigPathEnvVar)
}
}
return config.LoadConfiguration(configPath)
}
// initializeStorage initializes the storage provider
//
// Q: "TwiN, why are you putting this here? Wouldn't it make more sense to have this in the config?!"
// A: Yes. Yes it would make more sense to have it in the config package. But I don't want to import
// the massive SQL dependencies just because I want to import the config, so here we are.
func initializeStorage(cfg *config.Config) {
err := store.Initialize(cfg.Storage)
if err != nil {
panic(err)
}
// Remove all SuiteStatuses that represent suites which no longer exist in the configuration
var suiteKeys []string
for _, suite := range cfg.Suites {
suiteKeys = append(suiteKeys, suite.Key())
}
numberOfSuiteStatusesDeleted := store.Get().DeleteAllSuiteStatusesNotInKeys(suiteKeys)
if numberOfSuiteStatusesDeleted > 0 {
logr.Infof("[main.initializeStorage] Deleted %d suite statuses because their matching suites no longer existed", numberOfSuiteStatusesDeleted)
}
// Remove all EndpointStatus that represent endpoints which no longer exist in the configuration
var keys []string
for _, ep := range cfg.Endpoints {
keys = append(keys, ep.Key())
}
for _, ee := range cfg.ExternalEndpoints {
keys = append(keys, ee.Key())
}
// Also add endpoints that are part of suites
for _, suite := range cfg.Suites {
for _, ep := range suite.Endpoints {
keys = append(keys, ep.Key())
}
}
logr.Infof("[main.initializeStorage] Total endpoint keys to preserve: %d", len(keys))
numberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys)
if numberOfEndpointStatusesDeleted > 0 {
logr.Infof("[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted)
}
// Clean up the triggered alerts from the storage provider and load valid triggered endpoint alerts
numberOfPersistedTriggeredAlertsLoaded := 0
for _, ep := range cfg.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 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 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++
}
}
}
for _, ee := range cfg.ExternalEndpoints {
var checksums []string
for _, alert := range ee.Alerts {
if alert.IsEnabled() {
checksums = append(checksums, alert.Checksum())
}
}
convertedEndpoint := ee.ToEndpoint()
numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(convertedEndpoint, checksums)
if numberOfTriggeredAlertsDeleted > 0 {
logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ee.Key())
}
for _, alert := range ee.Alerts {
exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(convertedEndpoint, alert)
if err != nil {
logr.Errorf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ee.Key(), err.Error())
continue
}
if exists {
alert.Triggered, alert.ResolveKey = true, resolveKey
ee.NumberOfSuccessesInARow, ee.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold
numberOfPersistedTriggeredAlertsLoaded++
}
}
}
// 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)
if cfg.HasLoadedConfigurationBeenModified() {
logr.Info("[main.listenToConfigurationFileChanges] Configuration file has been modified")
stop(cfg)
time.Sleep(time.Second) // Wait a bit to make sure everything is done.
save()
updatedConfig, err := loadConfiguration()
if err != nil {
if cfg.SkipInvalidConfigUpdate {
logr.Errorf("[main.listenToConfigurationFileChanges] Failed to load new configuration: %s", err.Error())
logr.Error("[main.listenToConfigurationFileChanges] The configuration file was updated, but it is not valid. The old configuration will continue being used.")
// Update the last file modification time to avoid trying to process the same invalid configuration again
cfg.UpdateLastFileModTime()
continue
} else {
panic(err)
}
}
store.Get().Close()
initializeStorage(updatedConfig)
start(updatedConfig)
return
}
}
}
================================================
FILE: metrics/metrics.go
================================================
package metrics
import (
"strconv"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/prometheus/client_golang/prometheus"
)
const namespace = "gatus" // The prefix of the metrics
var (
resultTotal *prometheus.CounterVec
resultDurationSeconds *prometheus.GaugeVec
resultConnectedTotal *prometheus.CounterVec
resultCodeTotal *prometheus.CounterVec
resultCertificateExpirationSeconds *prometheus.GaugeVec
resultDomainExpirationSeconds *prometheus.GaugeVec
resultEndpointSuccess *prometheus.GaugeVec
// Suite metrics
suiteResultTotal *prometheus.CounterVec
suiteResultDurationSeconds *prometheus.GaugeVec
suiteResultSuccess *prometheus.GaugeVec
// Track if metrics have been initialized to prevent duplicate registration
metricsInitialized bool
currentRegisterer prometheus.Registerer
)
// UnregisterPrometheusMetrics unregisters all previously registered metrics
func UnregisterPrometheusMetrics() {
if !metricsInitialized || currentRegisterer == nil {
return
}
// Unregister all metrics if they exist
if resultTotal != nil {
currentRegisterer.Unregister(resultTotal)
}
if resultDurationSeconds != nil {
currentRegisterer.Unregister(resultDurationSeconds)
}
if resultConnectedTotal != nil {
currentRegisterer.Unregister(resultConnectedTotal)
}
if resultCodeTotal != nil {
currentRegisterer.Unregister(resultCodeTotal)
}
if resultCertificateExpirationSeconds != nil {
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
}
if resultDomainExpirationSeconds != nil {
currentRegisterer.Unregister(resultDomainExpirationSeconds)
}
if resultEndpointSuccess != nil {
currentRegisterer.Unregister(resultEndpointSuccess)
}
// Unregister suite metrics
if suiteResultTotal != nil {
currentRegisterer.Unregister(suiteResultTotal)
}
if suiteResultDurationSeconds != nil {
currentRegisterer.Unregister(suiteResultDurationSeconds)
}
if suiteResultSuccess != nil {
currentRegisterer.Unregister(suiteResultSuccess)
}
metricsInitialized = false
currentRegisterer = nil
}
func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer) {
// If metrics are already initialized, unregister them first
if metricsInitialized {
UnregisterPrometheusMetrics()
}
if reg == nil {
reg = prometheus.DefaultRegisterer
}
// Store the registerer for later unregistration
currentRegisterer = reg
extraLabels := cfg.GetUniqueExtraMetricLabels()
resultTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_total",
Help: "Number of results per endpoint",
}, append([]string{"key", "group", "name", "type", "success"}, extraLabels...))
reg.MustRegister(resultTotal)
resultDurationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_duration_seconds",
Help: "Duration of the request in seconds",
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultDurationSeconds)
resultConnectedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_connected_total",
Help: "Total number of results in which a connection was successfully established",
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultConnectedTotal)
resultCodeTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "results_code_total",
Help: "Total number of results by code",
}, append([]string{"key", "group", "name", "type", "code"}, extraLabels...))
reg.MustRegister(resultCodeTotal)
resultCertificateExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_certificate_expiration_seconds",
Help: "Number of seconds until the certificate expires",
}, 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",
Help: "Displays whether or not the endpoint was a success",
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultEndpointSuccess)
// Suite metrics
suiteResultTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Name: "suite_results_total",
Help: "Total number of suite executions",
}, append([]string{"key", "group", "name", "success"}, extraLabels...))
reg.MustRegister(suiteResultTotal)
suiteResultDurationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "suite_results_duration_seconds",
Help: "Duration of suite execution in seconds",
}, append([]string{"key", "group", "name"}, extraLabels...))
reg.MustRegister(suiteResultDurationSeconds)
suiteResultSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "suite_results_success",
Help: "Whether the suite execution was successful (1) or not (0)",
}, append([]string{"key", "group", "name"}, extraLabels...))
reg.MustRegister(suiteResultSuccess)
// Mark as initialized
metricsInitialized = true
}
// PublishMetricsForEndpoint publishes metrics for the given endpoint and its result.
// These metrics will be exposed at /metrics if the metrics are enabled
func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, extraLabels []string) {
var labelValues []string
for _, label := range extraLabels {
if value, ok := ep.ExtraLabels[label]; ok {
labelValues = append(labelValues, value)
} else {
labelValues = append(labelValues, "")
}
}
endpointType := ep.Type()
resultTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)}, labelValues...)...).Inc()
resultDurationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.Duration.Seconds())
if result.Connected {
resultConnectedTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Inc()
}
if result.DNSRCode != "" {
resultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), result.DNSRCode}, labelValues...)...).Inc()
}
if result.HTTPStatus != 0 {
resultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)}, labelValues...)...).Inc()
}
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 {
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(0)
}
}
// PublishMetricsForSuite publishes metrics for the given suite and its result.
// These metrics will be exposed at /metrics if the metrics are enabled
func PublishMetricsForSuite(s *suite.Suite, result *suite.Result, extraLabels []string) {
if !metricsInitialized {
return
}
var labelValues []string
// For now, suites don't have ExtraLabels, so we'll use empty values
// This maintains consistency with endpoint metrics structure
for range extraLabels {
labelValues = append(labelValues, "")
}
// Publish suite execution counter
suiteResultTotal.WithLabelValues(
append([]string{s.Key(), s.Group, s.Name, strconv.FormatBool(result.Success)}, labelValues...)...,
).Inc()
// Publish suite duration
suiteResultDurationSeconds.WithLabelValues(
append([]string{s.Key(), s.Group, s.Name}, labelValues...)...,
).Set(result.Duration.Seconds())
// Publish suite success status
if result.Success {
suiteResultSuccess.WithLabelValues(
append([]string{s.Key(), s.Group, s.Name}, labelValues...)...,
).Set(1)
} else {
suiteResultSuccess.WithLabelValues(
append([]string{s.Key(), s.Group, s.Name}, labelValues...)...,
).Set(0)
}
}
================================================
FILE: metrics/metrics_test.go
================================================
package metrics
import (
"bytes"
"testing"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
)
// TestInitializePrometheusMetrics tests metrics initialization with extraLabels.
// Note: Because of the global Prometheus registry, this test can only safely verify one label set per process.
// If the function is called with a different set of labels for the same metric, a panic will occur.
func TestInitializePrometheusMetrics(t *testing.T) {
cfgWithExtras := &config.Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "TestEP",
Group: "G",
URL: "http://x/",
ExtraLabels: map[string]string{
"foo": "foo-val",
"hello": "world-val",
},
},
},
}
reg := prometheus.NewRegistry()
InitializePrometheusMetrics(cfgWithExtras, reg)
// Metrics variables should be non-nil
if resultTotal == nil {
t.Error("resultTotal metric not initialized")
}
if resultDurationSeconds == nil {
t.Error("resultDurationSeconds metric not initialized")
}
if resultConnectedTotal == nil {
t.Error("resultConnectedTotal metric not initialized")
}
if resultCodeTotal == nil {
t.Error("resultCodeTotal metric not initialized")
}
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")
}
defer func() {
if r := recover(); r != nil {
t.Errorf("resultTotal.WithLabelValues panicked: %v", r)
}
}()
_ = resultTotal.WithLabelValues("k", "g", "n", "ty", "true", "fval", "hval")
}
// TestPublishMetricsForEndpoint_withExtraLabels ensures extraLabels are included in the exported metrics.
func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {
// Only test one label set per process due to Prometheus registry limits.
reg := prometheus.NewRegistry()
cfg := &config.Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "ep-extra",
URL: "https://sample.com",
ExtraLabels: map[string]string{
"foo": "my-foo",
"bar": "my-bar",
},
},
},
}
InitializePrometheusMetrics(cfg, reg)
ep := &endpoint.Endpoint{
Name: "ep-extra",
Group: "g1",
URL: "https://sample.com",
ExtraLabels: map[string]string{
"foo": "my-foo",
"bar": "my-bar",
},
}
result := &endpoint.Result{
HTTPStatus: 200,
Connected: true,
Duration: 2340 * time.Millisecond,
Success: true,
}
// Get labels in sorted order as per GetUniqueExtraMetricLabels
extraLabels := cfg.GetUniqueExtraMetricLabels()
PublishMetricsForEndpoint(ep, result, extraLabels)
expected := `
# HELP gatus_results_total Number of results per endpoint
# TYPE gatus_results_total counter
gatus_results_total{bar="my-bar",foo="my-foo",group="g1",key="g1_ep-extra",name="ep-extra",success="true",type="HTTP"} 1
`
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(expected), "gatus_results_total")
if err != nil {
t.Error("metrics export does not include extraLabels as expected:", err)
}
}
func TestPublishMetricsForEndpoint(t *testing.T) {
reg := prometheus.NewRegistry()
InitializePrometheusMetrics(&config.Config{}, reg)
httpEndpoint := &endpoint.Endpoint{Name: "http-ep-name", Group: "http-ep-group", URL: "https://example.org"}
PublishMetricsForEndpoint(httpEndpoint, &endpoint.Result{
HTTPStatus: 200,
Connected: true,
Duration: 123 * time.Millisecond,
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
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
# HELP gatus_results_connected_total Total number of results in which a connection was successfully established
# TYPE gatus_results_connected_total counter
gatus_results_connected_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
# HELP gatus_results_duration_seconds Duration of the request in seconds
# TYPE gatus_results_duration_seconds gauge
gatus_results_duration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0.123
# HELP gatus_results_total Number of results per endpoint
# TYPE gatus_results_total counter
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="true",type="HTTP"} 1
# 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
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds", "gatus_results_endpoint_success")
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
PublishMetricsForEndpoint(httpEndpoint, &endpoint.Result{
HTTPStatus: 200,
Connected: true,
Duration: 125 * time.Millisecond,
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
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
# HELP gatus_results_connected_total Total number of results in which a connection was successfully established
# TYPE gatus_results_connected_total counter
gatus_results_connected_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
# HELP gatus_results_duration_seconds Duration of the request in seconds
# TYPE gatus_results_duration_seconds gauge
gatus_results_duration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0.125
# HELP gatus_results_total Number of results per endpoint
# TYPE gatus_results_total counter
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="false",type="HTTP"} 1
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="true",type="HTTP"} 1
# 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
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds", "gatus_results_endpoint_success")
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
dnsEndpoint := &endpoint.Endpoint{
Name: "dns-ep-name", Group: "dns-ep-group", URL: "8.8.8.8", DNSConfig: &dns.Config{
QueryType: "A",
QueryName: "example.com.",
},
}
PublishMetricsForEndpoint(dnsEndpoint, &endpoint.Result{
DNSRCode: "NOERROR",
Connected: true,
Duration: 50 * time.Millisecond,
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[DNS_RCODE] == NOERROR", Success: true},
},
Success: true,
}, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
# TYPE gatus_results_code_total counter
gatus_results_code_total{code="200",group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
gatus_results_code_total{code="NOERROR",group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",type="DNS"} 1
# HELP gatus_results_connected_total Total number of results in which a connection was successfully established
# TYPE gatus_results_connected_total counter
gatus_results_connected_total{group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",type="DNS"} 1
gatus_results_connected_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 2
# HELP gatus_results_duration_seconds Duration of the request in seconds
# TYPE gatus_results_duration_seconds gauge
gatus_results_duration_seconds{group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",type="DNS"} 0.05
gatus_results_duration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0.125
# HELP gatus_results_total Number of results per endpoint
# TYPE gatus_results_total counter
gatus_results_total{group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",success="true",type="DNS"} 1
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="false",type="HTTP"} 1
gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",success="true",type="HTTP"} 1
# 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_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge
gatus_results_endpoint_success{group="dns-ep-group",key="dns-ep-group_dns-ep-name",name="dns-ep-name",type="DNS"} 1
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
`), "gatus_results_code_total", "gatus_results_connected_total", "gatus_results_duration_seconds", "gatus_results_total", "gatus_results_certificate_expiration_seconds", "gatus_results_endpoint_success")
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
}
func TestPublishMetricsForSuite(t *testing.T) {
reg := prometheus.NewRegistry()
InitializePrometheusMetrics(&config.Config{}, reg)
testSuite := &suite.Suite{
Name: "test-suite",
Group: "test-group",
}
// Test successful suite execution
successResult := &suite.Result{
Success: true,
Duration: 5 * time.Second,
Name: "test-suite",
Group: "test-group",
}
PublishMetricsForSuite(testSuite, successResult, []string{})
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_suite_results_duration_seconds Duration of suite execution in seconds
# TYPE gatus_suite_results_duration_seconds gauge
gatus_suite_results_duration_seconds{group="test-group",key="test-group_test-suite",name="test-suite"} 5
# HELP gatus_suite_results_success Whether the suite execution was successful (1) or not (0)
# TYPE gatus_suite_results_success gauge
gatus_suite_results_success{group="test-group",key="test-group_test-suite",name="test-suite"} 1
# HELP gatus_suite_results_total Total number of suite executions
# TYPE gatus_suite_results_total counter
gatus_suite_results_total{group="test-group",key="test-group_test-suite",name="test-suite",success="true"} 1
`), "gatus_suite_results_duration_seconds", "gatus_suite_results_success", "gatus_suite_results_total")
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
// Test failed suite execution
failureResult := &suite.Result{
Success: false,
Duration: 10 * time.Second,
Name: "test-suite",
Group: "test-group",
}
PublishMetricsForSuite(testSuite, failureResult, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_suite_results_duration_seconds Duration of suite execution in seconds
# TYPE gatus_suite_results_duration_seconds gauge
gatus_suite_results_duration_seconds{group="test-group",key="test-group_test-suite",name="test-suite"} 10
# HELP gatus_suite_results_success Whether the suite execution was successful (1) or not (0)
# TYPE gatus_suite_results_success gauge
gatus_suite_results_success{group="test-group",key="test-group_test-suite",name="test-suite"} 0
# HELP gatus_suite_results_total Total number of suite executions
# TYPE gatus_suite_results_total counter
gatus_suite_results_total{group="test-group",key="test-group_test-suite",name="test-suite",success="false"} 1
gatus_suite_results_total{group="test-group",key="test-group_test-suite",name="test-suite",success="true"} 1
`), "gatus_suite_results_duration_seconds", "gatus_suite_results_success", "gatus_suite_results_total")
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
}
func TestPublishMetricsForSuite_NoGroup(t *testing.T) {
reg := prometheus.NewRegistry()
InitializePrometheusMetrics(&config.Config{}, reg)
testSuite := &suite.Suite{
Name: "no-group-suite",
Group: "",
}
result := &suite.Result{
Success: true,
Duration: 3 * time.Second,
Name: "no-group-suite",
Group: "",
}
PublishMetricsForSuite(testSuite, result, []string{})
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_suite_results_duration_seconds Duration of suite execution in seconds
# TYPE gatus_suite_results_duration_seconds gauge
gatus_suite_results_duration_seconds{group="",key="_no-group-suite",name="no-group-suite"} 3
# HELP gatus_suite_results_success Whether the suite execution was successful (1) or not (0)
# TYPE gatus_suite_results_success gauge
gatus_suite_results_success{group="",key="_no-group-suite",name="no-group-suite"} 1
# HELP gatus_suite_results_total Total number of suite executions
# TYPE gatus_suite_results_total counter
gatus_suite_results_total{group="",key="_no-group-suite",name="no-group-suite",success="true"} 1
`), "gatus_suite_results_duration_seconds", "gatus_suite_results_success", "gatus_suite_results_total")
if err != nil {
t.Errorf("Expected no errors but got: %v", err)
}
}
================================================
FILE: pattern/pattern.go
================================================
package pattern
import (
"path/filepath"
"strings"
)
// Match checks whether a string matches a pattern
func Match(pattern, s string) bool {
if pattern == "*" {
return true
}
// Separators found in the string break filepath.Match, so we'll remove all of them.
// This has a pretty significant impact on performance when there are separators in
// the strings, but at least it doesn't break filepath.Match.
s = strings.ReplaceAll(s, string(filepath.Separator), "")
pattern = strings.ReplaceAll(pattern, string(filepath.Separator), "")
matched, _ := filepath.Match(pattern, s)
return matched
}
================================================
FILE: pattern/pattern_bench_test.go
================================================
package pattern
import "testing"
func BenchmarkMatch(b *testing.B) {
for n := 0; n < b.N; n++ {
if !Match("*ing*", "livingroom") {
b.Error("should've matched")
}
}
b.ReportAllocs()
}
func BenchmarkMatchWithBackslash(b *testing.B) {
for n := 0; n < b.N; n++ {
if !Match("*ing*", "living\\room") {
b.Error("should've matched")
}
}
b.ReportAllocs()
}
================================================
FILE: pattern/pattern_test.go
================================================
package pattern
import (
"fmt"
"testing"
)
func TestMatch(t *testing.T) {
testMatch(t, "*", "livingroom_123", true)
testMatch(t, "**", "livingroom_123", true)
testMatch(t, "living*", "livingroom_123", true)
testMatch(t, "*living*", "livingroom_123", true)
testMatch(t, "*123", "livingroom_123", true)
testMatch(t, "*_*", "livingroom_123", true)
testMatch(t, "living*_*3", "livingroom_123", true)
testMatch(t, "living*room_*3", "livingroom_123", true)
testMatch(t, "living*room_*3", "livingroom_123", true)
testMatch(t, "*vin*om*2*", "livingroom_123", true)
testMatch(t, "livingroom_123", "livingroom_123", true)
testMatch(t, "*livingroom_123*", "livingroom_123", true)
testMatch(t, "*test*", "\\test", true)
testMatch(t, "livingroom", "livingroom_123", false)
testMatch(t, "livingroom123", "livingroom_123", false)
testMatch(t, "what", "livingroom_123", false)
testMatch(t, "*what*", "livingroom_123", false)
testMatch(t, "*.*", "livingroom_123", false)
testMatch(t, "room*123", "livingroom_123", false)
}
func testMatch(t *testing.T, pattern, key string, expectedToMatch bool) {
t.Run(fmt.Sprintf("pattern '%s' from '%s'", pattern, key), func(t *testing.T) {
matched := Match(pattern, key)
if expectedToMatch {
if !matched {
t.Errorf("%s should've matched pattern '%s'", key, pattern)
}
} else {
if matched {
t.Errorf("%s shouldn't have matched pattern '%s'", key, pattern)
}
}
})
}
================================================
FILE: security/basic.go
================================================
package security
// BasicConfig is the configuration for Basic authentication
type BasicConfig struct {
// Username is the name which will need to be used for a successful authentication
Username string `yaml:"username"`
// PasswordBcryptHashBase64Encoded is the base64 encoded string of the Bcrypt hash of the password to use to
// authenticate using basic auth.
PasswordBcryptHashBase64Encoded string `yaml:"password-bcrypt-base64"`
}
// isValid returns whether the basic security configuration is valid or not
func (c *BasicConfig) isValid() bool {
return len(c.Username) > 0 && len(c.PasswordBcryptHashBase64Encoded) > 0
}
================================================
FILE: security/basic_test.go
================================================
package security
import "testing"
func TestBasicConfig_IsValidUsingBcrypt(t *testing.T) {
basicConfig := &BasicConfig{
Username: "admin",
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
}
if !basicConfig.isValid() {
t.Error("basicConfig should've been valid")
}
}
func TestBasicConfig_IsValidWhenPasswordIsInvalidUsingBcrypt(t *testing.T) {
basicConfig := &BasicConfig{
Username: "admin",
PasswordBcryptHashBase64Encoded: "",
}
if basicConfig.isValid() {
t.Error("basicConfig shouldn't have been valid")
}
}
================================================
FILE: security/config.go
================================================
package security
import (
"encoding/base64"
"net/http"
g8 "github.com/TwiN/g8/v2"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor"
"github.com/gofiber/fiber/v2/middleware/basicauth"
"golang.org/x/crypto/bcrypt"
)
const (
cookieNameState = "gatus_state"
cookieNameNonce = "gatus_nonce"
cookieNameSession = "gatus_session"
)
// Config is the security configuration for Gatus
type Config struct {
Basic *BasicConfig `yaml:"basic,omitempty"`
OIDC *OIDCConfig `yaml:"oidc,omitempty"`
gate *g8.Gate
}
// 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
func (c *Config) RegisterHandlers(router fiber.Router) error {
if c.OIDC != nil {
if err := c.OIDC.initialize(); err != nil {
return err
}
router.All("/oidc/login", c.OIDC.loginHandler)
router.All("/authorization-code/callback", adaptor.HTTPHandlerFunc(c.OIDC.callbackHandler))
}
return nil
}
// ApplySecurityMiddleware applies an authentication middleware to the router passed.
// The router passed should be a sub-router in charge of handlers that require authentication.
func (c *Config) ApplySecurityMiddleware(router fiber.Router) error {
if c.OIDC != nil {
// We're going to use g8 for session handling
clientProvider := g8.NewClientProvider(func(token string) *g8.Client {
if _, exists := sessions.Get(token); exists {
return g8.NewClient(token)
}
return nil
})
customTokenExtractorFunc := func(request *http.Request) string {
sessionCookie, err := request.Cookie(cookieNameSession)
if err != nil {
return ""
}
return sessionCookie.Value
}
// TODO: g8: Add a way to update cookie after? would need the writer
authorizationService := g8.NewAuthorizationService().WithClientProvider(clientProvider)
c.gate = g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
router.Use(adaptor.HTTPMiddleware(c.gate.Protect))
} else if c.Basic != nil {
var decodedBcryptHash []byte
if len(c.Basic.PasswordBcryptHashBase64Encoded) > 0 {
var err error
decodedBcryptHash, err = base64.URLEncoding.DecodeString(c.Basic.PasswordBcryptHashBase64Encoded)
if err != nil {
return err
}
}
router.Use(basicauth.New(basicauth.Config{
Authorizer: func(username, password string) bool {
if len(c.Basic.PasswordBcryptHashBase64Encoded) > 0 {
if username != c.Basic.Username || bcrypt.CompareHashAndPassword(decodedBcryptHash, []byte(password)) != nil {
return false
}
}
return true
},
Unauthorized: func(ctx *fiber.Ctx) error {
ctx.Set("WWW-Authenticate", "Basic")
return ctx.Status(401).SendString("Unauthorized")
},
}))
}
return nil
}
// IsAuthenticated checks whether the user is authenticated
// If the Config does not warrant authentication, it will always return true.
func (c *Config) IsAuthenticated(ctx *fiber.Ctx) bool {
if c.gate != nil {
// TODO: Update g8 to support fasthttp natively? (see g8's fasthttp branch)
request, err := adaptor.ConvertRequest(ctx, false)
if err != nil {
logr.Errorf("[security.IsAuthenticated] Unexpected error converting request: %v", err)
return false
}
token := c.gate.ExtractTokenFromRequest(request)
_, hasSession := sessions.Get(token)
return hasSession
}
return false
}
================================================
FILE: security/config_test.go
================================================
package security
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"golang.org/x/oauth2"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
c := &Config{
Basic: nil,
OIDC: nil,
}
if c.ValidateAndSetDefaults() {
t.Error("expected empty config to be valid")
}
}
func TestConfig_ApplySecurityMiddleware(t *testing.T) {
///////////
// BASIC //
///////////
t.Run("basic", func(t *testing.T) {
// Bcrypt
c := &Config{Basic: &BasicConfig{
Username: "john.doe",
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
}}
app := fiber.New()
if err := c.ApplySecurityMiddleware(app); err != nil {
t.Error("expected no error, got", err)
}
app.Get("/test", func(c *fiber.Ctx) error {
return c.SendStatus(200)
})
// Try to access the route without basic auth
request := httptest.NewRequest("GET", "/test", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 401 {
t.Error("expected code to be 401, but was", response.StatusCode)
}
// Try again, but with basic auth
request = httptest.NewRequest("GET", "/test", http.NoBody)
request.SetBasicAuth("john.doe", "hunter2")
response, err = app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 200 {
t.Error("expected code to be 200, but was", response.StatusCode)
}
})
//////////
// OIDC //
//////////
t.Run("oidc", func(t *testing.T) {
c := &Config{OIDC: &OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
SessionTTL: DefaultOIDCSessionTTL,
oauth2Config: oauth2.Config{},
verifier: nil,
}}
app := fiber.New()
if err := c.ApplySecurityMiddleware(app); err != nil {
t.Error("expected no error, got", err)
}
app.Get("/test", func(c *fiber.Ctx) error {
return c.SendStatus(200)
})
// Try without any session cookie
request := httptest.NewRequest("GET", "/test", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 401 {
t.Error("expected code to be 401, but was", response.StatusCode)
}
// Try with a session cookie
request = httptest.NewRequest("GET", "/test", http.NoBody)
request.AddCookie(&http.Cookie{Name: "session", Value: "123"})
response, err = app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 401 {
t.Error("expected code to be 401, but was", response.StatusCode)
}
})
}
func TestConfig_RegisterHandlers(t *testing.T) {
c := &Config{}
app := fiber.New()
c.RegisterHandlers(app)
// Try to access the OIDC handler. This should fail, because the security config doesn't have OIDC
request := httptest.NewRequest("GET", "/oidc/login", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 404 {
t.Error("expected code to be 404, but was", response.StatusCode)
}
// Set an empty OIDC config. This should fail, because the IssuerURL is required.
c.OIDC = &OIDCConfig{}
if err := c.RegisterHandlers(app); err == nil {
t.Fatal("expected an error, but got none")
}
// Set the OIDC config and try again
c.OIDC = &OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
}
if err := c.RegisterHandlers(app); err != nil {
t.Fatal("expected no error, but got", err)
}
request = httptest.NewRequest("GET", "/oidc/login", http.NoBody)
response, err = app.Test(request)
if err != nil {
t.Fatal("expected no error, got", err)
}
if response.StatusCode != 302 {
t.Error("expected code to be 302, but was", response.StatusCode)
}
}
================================================
FILE: security/oidc.go
================================================
package security
import (
"context"
"net/http"
"strings"
"time"
"github.com/TwiN/logr"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"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
SessionTTL time.Duration `yaml:"session-ttl"` // e.g. 8h. Defaults to 8 hours
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
}
// 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
}
func (c *OIDCConfig) initialize() error {
provider, err := oidc.NewProvider(context.Background(), c.IssuerURL)
if err != nil {
return err
}
c.verifier = provider.Verifier(&oidc.Config{ClientID: c.ClientID})
// Configure an OpenID Connect aware OAuth2 client.
c.oauth2Config = oauth2.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: c.Scopes,
RedirectURL: c.RedirectURL,
Endpoint: provider.Endpoint(),
}
return nil
}
func (c *OIDCConfig) loginHandler(ctx *fiber.Ctx) error {
state, nonce := uuid.NewString(), uuid.NewString()
ctx.Cookie(&fiber.Cookie{
Name: cookieNameState,
Value: state,
Path: "/",
MaxAge: int(time.Hour.Seconds()),
SameSite: "lax",
HTTPOnly: true,
})
ctx.Cookie(&fiber.Cookie{
Name: cookieNameNonce,
Value: nonce,
Path: "/",
MaxAge: int(time.Hour.Seconds()),
SameSite: "lax",
HTTPOnly: true,
})
return ctx.Redirect(c.oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
}
func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { // TODO: Migrate to a native fiber handler
// Check if there's an error
if len(r.URL.Query().Get("error")) > 0 {
http.Error(w, r.URL.Query().Get("error")+": "+r.URL.Query().Get("error_description"), http.StatusBadRequest)
return
}
// Ensure that the state has the expected value
state, err := r.Cookie(cookieNameState)
if err != nil {
http.Error(w, "state not found", http.StatusBadRequest)
return
}
if r.URL.Query().Get("state") != state.Value {
http.Error(w, "state did not match", http.StatusBadRequest)
return
}
// Validate token
oauth2Token, err := c.oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
http.Error(w, "Error exchanging token: "+err.Error(), http.StatusInternalServerError)
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
http.Error(w, "Missing 'id_token' in oauth2 token", http.StatusInternalServerError)
return
}
idToken, err := c.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Error(w, "Failed to verify id_token: "+err.Error(), http.StatusInternalServerError)
return
}
// Validate nonce
nonce, err := r.Cookie(cookieNameNonce)
if err != nil {
http.Error(w, "nonce not found", http.StatusBadRequest)
return
}
if idToken.Nonce != nonce.Value {
http.Error(w, "nonce did not match", http.StatusBadRequest)
return
}
if len(c.AllowedSubjects) == 0 {
// If there's no allowed subjects, all subjects are allowed.
c.setSessionCookie(w, idToken)
http.Redirect(w, r, "/", http.StatusFound)
return
}
for _, subject := range c.AllowedSubjects {
if strings.ToLower(subject) == strings.ToLower(idToken.Subject) {
c.setSessionCookie(w, idToken)
http.Redirect(w, r, "/", http.StatusFound)
return
}
}
logr.Debugf("[security.callbackHandler] Subject %s is not in the list of allowed subjects", idToken.Subject)
http.Redirect(w, r, "/?error=access_denied", http.StatusFound)
}
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, c.SessionTTL)
http.SetCookie(w, &http.Cookie{
Name: cookieNameSession,
Value: sessionID,
Path: "/",
MaxAge: int(c.SessionTTL.Seconds()),
SameSite: http.SameSiteStrictMode,
})
}
================================================
FILE: security/oidc_test.go
================================================
package security
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
func TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) {
c := &OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
SessionTTL: 0, // Not set! ValidateAndSetDefaults should set it to DefaultOIDCSessionTTL
}
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) {
c := &OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
ClientID: "client-id",
ClientSecret: "client-secret",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
}
if err := c.initialize(); err != nil {
t.Fatal("expected no error, but got", err)
}
// Try with no state cookie
request, _ := http.NewRequest("GET", "/authorization-code/callback", nil)
responseRecorder := httptest.NewRecorder()
c.callbackHandler(responseRecorder, request)
if responseRecorder.Code != http.StatusBadRequest {
t.Error("expected code to be 400, but was", responseRecorder.Code)
}
// Try with state cookie
request, _ = http.NewRequest("GET", "/authorization-code/callback", nil)
request.AddCookie(&http.Cookie{Name: cookieNameState, Value: "fake-state"})
responseRecorder = httptest.NewRecorder()
c.callbackHandler(responseRecorder, request)
if responseRecorder.Code != http.StatusBadRequest {
t.Error("expected code to be 400, but was", responseRecorder.Code)
}
// Try with state cookie and state query parameter
request, _ = http.NewRequest("GET", "/authorization-code/callback?state=fake-state", nil)
request.AddCookie(&http.Cookie{Name: cookieNameState, Value: "fake-state"})
responseRecorder = httptest.NewRecorder()
c.callbackHandler(responseRecorder, request)
// Exchange should fail, so 500.
if responseRecorder.Code != http.StatusInternalServerError {
t.Error("expected code to be 500, but was", responseRecorder.Code)
}
}
func TestOIDCConfig_setSessionCookie(t *testing.T) {
c := &OIDCConfig{}
responseRecorder := httptest.NewRecorder()
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
if len(responseRecorder.Result().Cookies()) == 0 {
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)
}
}
================================================
FILE: security/sessions.go
================================================
package security
import "github.com/TwiN/gocache/v2"
var sessions = gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed) // TODO: Move this to storage
================================================
FILE: storage/config.go
================================================
package storage
import (
"errors"
)
const (
DefaultMaximumNumberOfResults = 100
DefaultMaximumNumberOfEvents = 50
)
var (
ErrSQLStorageRequiresPath = errors.New("sql storage requires a non-empty path to be defined")
ErrMemoryStorageDoesNotSupportPath = errors.New("memory storage does not support persistence, use sqlite if you want persistence on file")
)
// Config is the configuration for storage
type Config struct {
// Path is the path used by the store to achieve persistence
// If blank, persistence is disabled.
// Note that not all Type support persistence
Path string `yaml:"path"`
// Type of store
// If blank, uses the default in-memory store
Type Type `yaml:"type"`
// Caching is whether to enable caching.
// This is used to drastically decrease read latency by pre-emptively caching writes
// as they happen, also known as the write-through caching strategy.
// Does not apply if Config.Type is not TypePostgres or TypeSQLite.
Caching bool `yaml:"caching,omitempty"`
// MaximumNumberOfResults is the number of results each endpoint should be able to provide
MaximumNumberOfResults int `yaml:"maximum-number-of-results,omitempty"`
// MaximumNumberOfEvents is the number of events each endpoint should be able to provide
MaximumNumberOfEvents int `yaml:"maximum-number-of-events,omitempty"`
}
// ValidateAndSetDefaults validates the configuration and sets the default values (if applicable)
func (c *Config) ValidateAndSetDefaults() error {
if c.Type == "" {
c.Type = TypeMemory
}
if (c.Type == TypePostgres || c.Type == TypeSQLite) && len(c.Path) == 0 {
return ErrSQLStorageRequiresPath
}
if c.Type == TypeMemory && len(c.Path) > 0 {
return ErrMemoryStorageDoesNotSupportPath
}
if c.MaximumNumberOfResults <= 0 {
c.MaximumNumberOfResults = DefaultMaximumNumberOfResults
}
if c.MaximumNumberOfEvents <= 0 {
c.MaximumNumberOfEvents = DefaultMaximumNumberOfEvents
}
return nil
}
================================================
FILE: storage/store/common/errors.go
================================================
package common
import "errors"
var (
ErrEndpointNotFound = errors.New("endpoint not found") // When an endpoint does not exist in the store
ErrSuiteNotFound = errors.New("suite not found") // When a suite does not exist in the store
ErrInvalidTimeRange = errors.New("'from' cannot be older than 'to'") // When an invalid time range is provided
)
================================================
FILE: storage/store/common/paging/endpoint_status_params.go
================================================
package paging
// EndpointStatusParams represents all parameters that can be used for paging purposes
type EndpointStatusParams struct {
EventsPage int // Number of the event page
EventsPageSize int // Size of the event page
ResultsPage int // Number of the result page
ResultsPageSize int // Size of the result page
}
// NewEndpointStatusParams creates a new EndpointStatusParams
func NewEndpointStatusParams() *EndpointStatusParams {
return &EndpointStatusParams{}
}
// WithEvents sets the values for EventsPage and EventsPageSize
func (params *EndpointStatusParams) WithEvents(page, pageSize int) *EndpointStatusParams {
params.EventsPage = page
params.EventsPageSize = pageSize
return params
}
// WithResults sets the values for ResultsPage and ResultsPageSize
func (params *EndpointStatusParams) WithResults(page, pageSize int) *EndpointStatusParams {
params.ResultsPage = page
params.ResultsPageSize = pageSize
return params
}
================================================
FILE: storage/store/common/paging/endpoint_status_params_test.go
================================================
package paging
import "testing"
func TestNewEndpointStatusParams(t *testing.T) {
type Scenario struct {
Name string
Params *EndpointStatusParams
ExpectedEventsPage int
ExpectedEventsPageSize int
ExpectedResultsPage int
ExpectedResultsPageSize int
}
scenarios := []Scenario{
{
Name: "empty-params",
Params: NewEndpointStatusParams(),
ExpectedEventsPage: 0,
ExpectedEventsPageSize: 0,
ExpectedResultsPage: 0,
ExpectedResultsPageSize: 0,
},
{
Name: "with-events-page-2-size-7",
Params: NewEndpointStatusParams().WithEvents(2, 7),
ExpectedEventsPage: 2,
ExpectedEventsPageSize: 7,
ExpectedResultsPage: 0,
ExpectedResultsPageSize: 0,
},
{
Name: "with-events-page-4-size-3-uptime",
Params: NewEndpointStatusParams().WithEvents(4, 3),
ExpectedEventsPage: 4,
ExpectedEventsPageSize: 3,
ExpectedResultsPage: 0,
ExpectedResultsPageSize: 0,
},
{
Name: "with-results-page-1-size-20-uptime",
Params: NewEndpointStatusParams().WithResults(1, 20),
ExpectedEventsPage: 0,
ExpectedEventsPageSize: 0,
ExpectedResultsPage: 1,
ExpectedResultsPageSize: 20,
},
{
Name: "with-results-page-2-size-10-events-page-3-size-50",
Params: NewEndpointStatusParams().WithResults(2, 10).WithEvents(3, 50),
ExpectedEventsPage: 3,
ExpectedEventsPageSize: 50,
ExpectedResultsPage: 2,
ExpectedResultsPageSize: 10,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.Params.EventsPage != scenario.ExpectedEventsPage {
t.Errorf("expected ExpectedEventsPage to be %d, was %d", scenario.ExpectedEventsPageSize, scenario.Params.EventsPage)
}
if scenario.Params.EventsPageSize != scenario.ExpectedEventsPageSize {
t.Errorf("expected EventsPageSize to be %d, was %d", scenario.ExpectedEventsPageSize, scenario.Params.EventsPageSize)
}
if scenario.Params.ResultsPage != scenario.ExpectedResultsPage {
t.Errorf("expected ResultsPage to be %d, was %d", scenario.ExpectedResultsPage, scenario.Params.ResultsPage)
}
if scenario.Params.ResultsPageSize != scenario.ExpectedResultsPageSize {
t.Errorf("expected ResultsPageSize to be %d, was %d", scenario.ExpectedResultsPageSize, scenario.Params.ResultsPageSize)
}
})
}
}
================================================
FILE: storage/store/common/paging/suite_status_params.go
================================================
package paging
// SuiteStatusParams represents the parameters for suite status queries
type SuiteStatusParams struct {
Page int // Page number
PageSize int // Number of results per page
}
// NewSuiteStatusParams creates a new SuiteStatusParams
func NewSuiteStatusParams() *SuiteStatusParams {
return &SuiteStatusParams{
Page: 1,
PageSize: 20,
}
}
// WithPagination sets the page and page size
func (params *SuiteStatusParams) WithPagination(page, pageSize int) *SuiteStatusParams {
params.Page = page
params.PageSize = pageSize
return params
}
================================================
FILE: storage/store/common/paging/suite_status_params_test.go
================================================
package paging
import (
"testing"
)
func TestNewSuiteStatusParams(t *testing.T) {
params := NewSuiteStatusParams()
if params == nil {
t.Fatal("NewSuiteStatusParams should not return nil")
}
if params.Page != 1 {
t.Errorf("expected default Page to be 1, got %d", params.Page)
}
if params.PageSize != 20 {
t.Errorf("expected default PageSize to be 20, got %d", params.PageSize)
}
}
func TestSuiteStatusParams_WithPagination(t *testing.T) {
tests := []struct {
name string
page int
pageSize int
expectedPage int
expectedSize int
}{
{
name: "valid pagination",
page: 2,
pageSize: 50,
expectedPage: 2,
expectedSize: 50,
},
{
name: "zero page",
page: 0,
pageSize: 10,
expectedPage: 0,
expectedSize: 10,
},
{
name: "negative page",
page: -1,
pageSize: 20,
expectedPage: -1,
expectedSize: 20,
},
{
name: "zero page size",
page: 1,
pageSize: 0,
expectedPage: 1,
expectedSize: 0,
},
{
name: "negative page size",
page: 1,
pageSize: -10,
expectedPage: 1,
expectedSize: -10,
},
{
name: "large values",
page: 1000,
pageSize: 10000,
expectedPage: 1000,
expectedSize: 10000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := NewSuiteStatusParams().WithPagination(tt.page, tt.pageSize)
if params.Page != tt.expectedPage {
t.Errorf("expected Page to be %d, got %d", tt.expectedPage, params.Page)
}
if params.PageSize != tt.expectedSize {
t.Errorf("expected PageSize to be %d, got %d", tt.expectedSize, params.PageSize)
}
})
}
}
func TestSuiteStatusParams_ChainedMethods(t *testing.T) {
params := NewSuiteStatusParams().
WithPagination(3, 100)
if params.Page != 3 {
t.Errorf("expected Page to be 3, got %d", params.Page)
}
if params.PageSize != 100 {
t.Errorf("expected PageSize to be 100, got %d", params.PageSize)
}
}
func TestSuiteStatusParams_OverwritePagination(t *testing.T) {
params := NewSuiteStatusParams()
// Set initial pagination
params.WithPagination(2, 50)
if params.Page != 2 || params.PageSize != 50 {
t.Error("initial pagination not set correctly")
}
// Overwrite pagination
params.WithPagination(5, 200)
if params.Page != 5 {
t.Errorf("expected Page to be overwritten to 5, got %d", params.Page)
}
if params.PageSize != 200 {
t.Errorf("expected PageSize to be overwritten to 200, got %d", params.PageSize)
}
}
func TestSuiteStatusParams_ReturnsSelf(t *testing.T) {
params := NewSuiteStatusParams()
// Verify WithPagination returns the same instance
result := params.WithPagination(1, 20)
if result != params {
t.Error("WithPagination should return the same instance for method chaining")
}
}
================================================
FILE: storage/store/memory/memory.go
================================================
package memory
import (
"slices"
"sort"
"sync"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/key"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/gocache/v2"
"github.com/TwiN/logr"
)
// Store that leverages gocache
type Store struct {
sync.RWMutex
endpointCache *gocache.Cache // Cache for endpoint statuses
suiteCache *gocache.Cache // Cache for suite statuses
maximumNumberOfResults int // maximum number of results that an endpoint can have
maximumNumberOfEvents int // maximum number of events that an endpoint can have
}
// NewStore creates a new store using gocache.Cache
//
// This store holds everything in memory, and if the file parameter is not blank,
// supports eventual persistence.
func NewStore(maximumNumberOfResults, maximumNumberOfEvents int) (*Store, error) {
store := &Store{
endpointCache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize),
suiteCache: gocache.NewCache().WithMaxSize(gocache.NoMaxSize),
maximumNumberOfResults: maximumNumberOfResults,
maximumNumberOfEvents: maximumNumberOfEvents,
}
return store, nil
}
// GetAllEndpointStatuses returns all monitored endpoint.Status
// with a subset of endpoint.Result defined by the page and pageSize parameters
func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error) {
s.RLock()
defer s.RUnlock()
allStatuses := s.endpointCache.GetAll()
pagedEndpointStatuses := make([]*endpoint.Status, 0, len(allStatuses))
for _, v := range allStatuses {
if status, ok := v.(*endpoint.Status); ok {
pagedEndpointStatuses = append(pagedEndpointStatuses, ShallowCopyEndpointStatus(status, params))
}
}
sort.Slice(pagedEndpointStatuses, func(i, j int) bool {
return pagedEndpointStatuses[i].Key < pagedEndpointStatuses[j].Key
})
return pagedEndpointStatuses, nil
}
// GetAllSuiteStatuses returns all monitored suite.Status
func (s *Store) GetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.Status, error) {
s.RLock()
defer s.RUnlock()
suiteStatuses := make([]*suite.Status, 0)
for _, v := range s.suiteCache.GetAll() {
if status, ok := v.(*suite.Status); ok {
suiteStatuses = append(suiteStatuses, ShallowCopySuiteStatus(status, params))
}
}
sort.Slice(suiteStatuses, func(i, j int) bool {
return suiteStatuses[i].Key < suiteStatuses[j].Key
})
return suiteStatuses, nil
}
// GetEndpointStatus returns the endpoint status for a given endpoint name in the given group
func (s *Store) GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*endpoint.Status, error) {
return s.GetEndpointStatusByKey(key.ConvertGroupAndNameToKey(groupName, endpointName), params)
}
// GetEndpointStatusByKey returns the endpoint status for a given key
func (s *Store) GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error) {
s.RLock()
defer s.RUnlock()
endpointStatus := s.endpointCache.GetValue(key)
if endpointStatus == nil {
return nil, common.ErrEndpointNotFound
}
return ShallowCopyEndpointStatus(endpointStatus.(*endpoint.Status), params), nil
}
// GetSuiteStatusByKey returns the suite status for a given key
func (s *Store) GetSuiteStatusByKey(key string, params *paging.SuiteStatusParams) (*suite.Status, error) {
s.RLock()
defer s.RUnlock()
suiteStatus := s.suiteCache.GetValue(key)
if suiteStatus == nil {
return nil, common.ErrSuiteNotFound
}
return ShallowCopySuiteStatus(suiteStatus.(*suite.Status), params), nil
}
// GetUptimeByKey returns the uptime percentage during a time range
func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) {
if from.After(to) {
return 0, common.ErrInvalidTimeRange
}
s.RLock()
defer s.RUnlock()
endpointStatus := s.endpointCache.GetValue(key)
if endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil {
return 0, common.ErrEndpointNotFound
}
successfulExecutions := uint64(0)
totalExecutions := uint64(0)
current := from
for to.Sub(current) >= 0 {
hourlyUnixTimestamp := current.Truncate(time.Hour).Unix()
hourlyStats := endpointStatus.(*endpoint.Status).Uptime.HourlyStatistics[hourlyUnixTimestamp]
if hourlyStats == nil || hourlyStats.TotalExecutions == 0 {
current = current.Add(time.Hour)
continue
}
successfulExecutions += hourlyStats.SuccessfulExecutions
totalExecutions += hourlyStats.TotalExecutions
current = current.Add(time.Hour)
}
if totalExecutions == 0 {
return 0, nil
}
return float64(successfulExecutions) / float64(totalExecutions), nil
}
// GetAverageResponseTimeByKey returns the average response time in milliseconds (value) during a time range
func (s *Store) GetAverageResponseTimeByKey(key string, from, to time.Time) (int, error) {
if from.After(to) {
return 0, common.ErrInvalidTimeRange
}
s.RLock()
defer s.RUnlock()
endpointStatus := s.endpointCache.GetValue(key)
if endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil {
return 0, common.ErrEndpointNotFound
}
current := from
var totalExecutions, totalResponseTime uint64
for to.Sub(current) >= 0 {
hourlyUnixTimestamp := current.Truncate(time.Hour).Unix()
hourlyStats := endpointStatus.(*endpoint.Status).Uptime.HourlyStatistics[hourlyUnixTimestamp]
if hourlyStats == nil || hourlyStats.TotalExecutions == 0 {
current = current.Add(time.Hour)
continue
}
totalExecutions += hourlyStats.TotalExecutions
totalResponseTime += hourlyStats.TotalExecutionsResponseTime
current = current.Add(time.Hour)
}
if totalExecutions == 0 {
return 0, nil
}
return int(float64(totalResponseTime) / float64(totalExecutions)), nil
}
// GetHourlyAverageResponseTimeByKey returns a map of hourly (key) average response time in milliseconds (value) during a time range
func (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error) {
if from.After(to) {
return nil, common.ErrInvalidTimeRange
}
s.RLock()
defer s.RUnlock()
endpointStatus := s.endpointCache.GetValue(key)
if endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil {
return nil, common.ErrEndpointNotFound
}
hourlyAverageResponseTimes := make(map[int64]int)
current := from
for to.Sub(current) >= 0 {
hourlyUnixTimestamp := current.Truncate(time.Hour).Unix()
hourlyStats := endpointStatus.(*endpoint.Status).Uptime.HourlyStatistics[hourlyUnixTimestamp]
if hourlyStats == nil || hourlyStats.TotalExecutions == 0 {
current = current.Add(time.Hour)
continue
}
hourlyAverageResponseTimes[hourlyUnixTimestamp] = int(float64(hourlyStats.TotalExecutionsResponseTime) / float64(hourlyStats.TotalExecutions))
current = current.Add(time.Hour)
}
return hourlyAverageResponseTimes, nil
}
// InsertEndpointResult adds the observed result for the specified endpoint into the store
func (s *Store) InsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Result) error {
endpointKey := ep.Key()
s.Lock()
status, exists := s.endpointCache.Get(endpointKey)
if !exists {
status = endpoint.NewStatus(ep.Group, ep.Name)
status.(*endpoint.Status).Events = append(status.(*endpoint.Status).Events, &endpoint.Event{
Type: endpoint.EventStart,
Timestamp: time.Now(),
})
}
AddResult(status.(*endpoint.Status), result, s.maximumNumberOfResults, s.maximumNumberOfEvents)
s.endpointCache.Set(endpointKey, status)
s.Unlock()
return nil
}
// InsertSuiteResult adds the observed result for the specified suite into the store
func (s *Store) InsertSuiteResult(su *suite.Suite, result *suite.Result) error {
s.Lock()
defer s.Unlock()
suiteKey := su.Key()
suiteStatus := s.suiteCache.GetValue(suiteKey)
if suiteStatus == nil {
suiteStatus = &suite.Status{
Name: su.Name,
Group: su.Group,
Key: su.Key(),
Results: []*suite.Result{},
}
logr.Debugf("[memory.InsertSuiteResult] Created new suite status for suiteKey=%s", suiteKey)
}
status := suiteStatus.(*suite.Status)
// Add the new result at the end (append like endpoint implementation)
status.Results = append(status.Results, result)
// Keep only the maximum number of results
if len(status.Results) > s.maximumNumberOfResults {
status.Results = status.Results[len(status.Results)-s.maximumNumberOfResults:]
}
s.suiteCache.Set(suiteKey, status)
logr.Debugf("[memory.InsertSuiteResult] Stored suite result for suiteKey=%s, total results=%d", suiteKey, len(status.Results))
return nil
}
// DeleteAllEndpointStatusesNotInKeys removes all Status that are not within the keys provided
func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int {
var keysToDelete []string
for _, existingKey := range s.endpointCache.GetKeysByPattern("*", 0) {
shouldDelete := !slices.Contains(keys, existingKey)
if shouldDelete {
keysToDelete = append(keysToDelete, existingKey)
}
}
return s.endpointCache.DeleteAll(keysToDelete)
}
// DeleteAllSuiteStatusesNotInKeys removes all suite statuses that are not within the keys provided
func (s *Store) DeleteAllSuiteStatusesNotInKeys(keys []string) int {
s.Lock()
defer s.Unlock()
keysToKeep := make(map[string]bool, len(keys))
for _, k := range keys {
keysToKeep[k] = true
}
var keysToDelete []string
for existingKey := range s.suiteCache.GetAll() {
if !keysToKeep[existingKey] {
keysToDelete = append(keysToDelete, existingKey)
}
}
return s.suiteCache.DeleteAll(keysToDelete)
}
// GetTriggeredEndpointAlert returns whether the triggered alert for the specified endpoint as well as the necessary information to resolve it
//
// Always returns that the alert does not exist for the in-memory store since it does not support persistence across restarts
func (s *Store) GetTriggeredEndpointAlert(ep *endpoint.Endpoint, alert *alert.Alert) (exists bool, resolveKey string, numberOfSuccessesInARow int, err error) {
return false, "", 0, nil
}
// UpsertTriggeredEndpointAlert inserts/updates a triggered alert for an endpoint
// Used for persistence of triggered alerts across application restarts
//
// Does nothing for the in-memory store since it does not support persistence across restarts
func (s *Store) UpsertTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error {
return nil
}
// DeleteTriggeredEndpointAlert deletes a triggered alert for an endpoint
//
// Does nothing for the in-memory store since it does not support persistence across restarts
func (s *Store) DeleteTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error {
return nil
}
// DeleteAllTriggeredAlertsNotInChecksumsByEndpoint removes all triggered alerts owned by an endpoint whose alert
// configurations are not provided in the checksums list.
// This prevents triggered alerts that have been removed or modified from lingering in the database.
//
// Does nothing for the in-memory store since it does not support persistence across restarts
func (s *Store) DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int {
return 0
}
// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp
func (s *Store) HasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error) {
s.RLock()
defer s.RUnlock()
endpointStatus := s.endpointCache.GetValue(key)
if endpointStatus == nil {
// If no endpoint exists, there's no newer status, so return false instead of an error
return false, nil
}
status, ok := endpointStatus.(*endpoint.Status)
if !ok {
return false, nil
}
for _, result := range status.Results {
if result.Timestamp.After(timestamp) {
return true, nil
}
}
return false, nil
}
// Clear deletes everything from the store
func (s *Store) Clear() {
s.endpointCache.Clear()
s.suiteCache.Clear()
}
// Save persists the cache to the store file
func (s *Store) Save() error {
return nil
}
// Close does nothing, because there's nothing to close
func (s *Store) Close() {
return
}
================================================
FILE: storage/store/memory/memory_test.go
================================================
package memory
import (
"sync"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
)
var (
firstCondition = endpoint.Condition("[STATUS] == 200")
secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
now = time.Now()
testEndpoint = endpoint.Endpoint{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: now,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: now,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
// Note that are much more extensive tests in /storage/store/store_test.go.
// This test is simply an extra sanity check
func TestStore_SanityCheck(t *testing.T) {
store, _ := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Clear()
defer store.Close()
store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
endpointStatuses, _ := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())
if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {
t.Fatalf("expected 1 EndpointStatus, got %d", numberOfEndpointStatuses)
}
store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
// Both results inserted are for the same endpoint, therefore, the count shouldn't have increased
endpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())
if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {
t.Fatalf("expected 1 EndpointStatus, got %d", numberOfEndpointStatuses)
}
if hourlyAverageResponseTime, err := store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); err != nil {
t.Errorf("expected no error, got %v", err)
} else if len(hourlyAverageResponseTime) != 1 {
t.Errorf("expected 1 hour to have had a result in the past 24 hours, got %d", len(hourlyAverageResponseTime))
}
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); uptime != 0.5 {
t.Errorf("expected uptime of last 24h to be 0.5, got %f", uptime)
}
if averageResponseTime, _ := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); averageResponseTime != 450 {
t.Errorf("expected average response time of last 24h to be 450, got %d", averageResponseTime)
}
ss, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, 20).WithEvents(1, 20))
if ss == nil {
t.Fatalf("Store should've had key '%s', but didn't", testEndpoint.Key())
}
if len(ss.Events) != 3 {
t.Errorf("Endpoint '%s' should've had 3 events, got %d", ss.Name, len(ss.Events))
}
if len(ss.Results) != 2 {
t.Errorf("Endpoint '%s' should've had 2 results, got %d", ss.Name, len(ss.Results))
}
if deleted := store.DeleteAllEndpointStatusesNotInKeys([]string{}); deleted != 1 {
t.Errorf("%d entries should've been deleted, got %d", 1, deleted)
}
}
func TestStore_Save(t *testing.T) {
store, err := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
err = store.Save()
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
store.Clear()
store.Close()
}
func TestStore_HasEndpointStatusNewerThan(t *testing.T) {
store, _ := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Clear()
defer store.Close()
// InsertEndpointResult a result
err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
if err != nil {
t.Fatalf("expected no error while inserting result, got %v", err)
}
// Check with a timestamp in the past
hasNewerStatus, err := store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !hasNewerStatus {
t.Fatal("expected to have a newer status, but didn't")
}
// Check with a timestamp in the future
hasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if hasNewerStatus {
t.Fatal("expected not to have a newer status, but did")
}
}
// TestStore_MixedEndpointsAndSuites tests that having both endpoints and suites in the cache
// doesn't cause issues with core operations
func TestStore_MixedEndpointsAndSuites(t *testing.T) {
// Helper function to create and populate a store with test data
setupStore := func(t *testing.T) (*Store, *endpoint.Endpoint, *endpoint.Endpoint, *endpoint.Endpoint, *endpoint.Endpoint, *suite.Suite) {
store, err := NewStore(100, 50)
if err != nil {
t.Fatal("expected no error, got", err)
}
// Create regular endpoints
endpoint1 := &endpoint.Endpoint{
Name: "endpoint1",
Group: "group1",
URL: "https://example.com/1",
}
endpoint2 := &endpoint.Endpoint{
Name: "endpoint2",
Group: "group2",
URL: "https://example.com/2",
}
// Create suite endpoints (these would be part of a suite)
suiteEndpoint1 := &endpoint.Endpoint{
Name: "suite-endpoint1",
Group: "suite-group",
URL: "https://example.com/suite1",
}
suiteEndpoint2 := &endpoint.Endpoint{
Name: "suite-endpoint2",
Group: "suite-group",
URL: "https://example.com/suite2",
}
// Create a suite
testSuite := &suite.Suite{
Name: "test-suite",
Group: "suite-group",
Endpoints: []*endpoint.Endpoint{
suiteEndpoint1,
suiteEndpoint2,
},
}
return store, endpoint1, endpoint2, suiteEndpoint1, suiteEndpoint2, testSuite
}
// Test 1: InsertEndpointResult endpoint results
t.Run("InsertEndpointResults", func(t *testing.T) {
store, endpoint1, endpoint2, suiteEndpoint1, suiteEndpoint2, _ := setupStore(t)
// InsertEndpointResult regular endpoint results
result1 := &endpoint.Result{
Success: true,
Timestamp: time.Now(),
Duration: 100 * time.Millisecond,
}
if err := store.InsertEndpointResult(endpoint1, result1); err != nil {
t.Fatalf("failed to insert endpoint1 result: %v", err)
}
result2 := &endpoint.Result{
Success: false,
Timestamp: time.Now(),
Duration: 200 * time.Millisecond,
Errors: []string{"error"},
}
if err := store.InsertEndpointResult(endpoint2, result2); err != nil {
t.Fatalf("failed to insert endpoint2 result: %v", err)
}
// InsertEndpointResult suite endpoint results
suiteResult1 := &endpoint.Result{
Success: true,
Timestamp: time.Now(),
Duration: 50 * time.Millisecond,
}
if err := store.InsertEndpointResult(suiteEndpoint1, suiteResult1); err != nil {
t.Fatalf("failed to insert suite endpoint1 result: %v", err)
}
suiteResult2 := &endpoint.Result{
Success: true,
Timestamp: time.Now(),
Duration: 75 * time.Millisecond,
}
if err := store.InsertEndpointResult(suiteEndpoint2, suiteResult2); err != nil {
t.Fatalf("failed to insert suite endpoint2 result: %v", err)
}
})
// Test 2: InsertEndpointResult suite result
t.Run("InsertSuiteResult", func(t *testing.T) {
store, _, _, _, _, testSuite := setupStore(t)
timestamp := time.Now()
suiteResult := &suite.Result{
Name: testSuite.Name,
Group: testSuite.Group,
Success: true,
Timestamp: timestamp,
Duration: 125 * time.Millisecond,
EndpointResults: []*endpoint.Result{
{Success: true, Duration: 50 * time.Millisecond},
{Success: true, Duration: 75 * time.Millisecond},
},
}
if err := store.InsertSuiteResult(testSuite, suiteResult); err != nil {
t.Fatalf("failed to insert suite result: %v", err)
}
// Verify the suite result was stored correctly
status, err := store.GetSuiteStatusByKey(testSuite.Key(), nil)
if err != nil {
t.Fatalf("failed to get suite status: %v", err)
}
if len(status.Results) != 1 {
t.Errorf("expected 1 suite result, got %d", len(status.Results))
}
stored := status.Results[0]
if stored.Name != testSuite.Name {
t.Errorf("expected result name %s, got %s", testSuite.Name, stored.Name)
}
if stored.Group != testSuite.Group {
t.Errorf("expected result group %s, got %s", testSuite.Group, stored.Group)
}
if !stored.Success {
t.Error("expected result to be successful")
}
if stored.Duration != 125*time.Millisecond {
t.Errorf("expected duration 125ms, got %v", stored.Duration)
}
if len(stored.EndpointResults) != 2 {
t.Errorf("expected 2 endpoint results, got %d", len(stored.EndpointResults))
}
})
// Test 3: GetAllEndpointStatuses should only return endpoints, not suites
t.Run("GetAllEndpointStatuses", func(t *testing.T) {
store, endpoint1, endpoint2, _, _, testSuite := setupStore(t)
// Insert standalone endpoint results only
store.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})
store.InsertEndpointResult(endpoint2, &endpoint.Result{Success: false, Timestamp: time.Now(), Duration: 200 * time.Millisecond})
// Suite endpoints should only exist as part of suite results, not as individual endpoint results
store.InsertSuiteResult(testSuite, &suite.Result{
Name: testSuite.Name, Group: testSuite.Group, Success: true,
Timestamp: time.Now(), Duration: 125 * time.Millisecond,
EndpointResults: []*endpoint.Result{
{Success: true, Duration: 50 * time.Millisecond, Name: "suite-endpoint1"},
{Success: true, Duration: 75 * time.Millisecond, Name: "suite-endpoint2"},
},
})
statuses, err := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
if err != nil {
t.Fatalf("failed to get all endpoint statuses: %v", err)
}
// Should have 2 endpoints (only standalone endpoints, not suite endpoints)
if len(statuses) != 2 {
t.Errorf("expected 2 endpoint statuses, got %d", len(statuses))
}
// Verify all are standalone endpoint statuses with correct data, not suite endpoints
expectedEndpoints := map[string]struct {
success bool
duration time.Duration
}{
"endpoint1": {success: true, duration: 100 * time.Millisecond},
"endpoint2": {success: false, duration: 200 * time.Millisecond},
}
for _, status := range statuses {
if status.Name == "" {
t.Error("endpoint status should have a name")
}
// Make sure none of them are the suite itself
if status.Name == "test-suite" {
t.Error("suite should not appear in endpoint statuses")
}
// Verify detailed endpoint data
expected, exists := expectedEndpoints[status.Name]
if !exists {
t.Errorf("unexpected endpoint name: %s", status.Name)
continue
}
// Check that endpoint has results and verify the data
if len(status.Results) != 1 {
t.Errorf("endpoint %s should have 1 result, got %d", status.Name, len(status.Results))
continue
}
result := status.Results[0]
if result.Success != expected.success {
t.Errorf("endpoint %s result success should be %v, got %v", status.Name, expected.success, result.Success)
}
if result.Duration != expected.duration {
t.Errorf("endpoint %s result duration should be %v, got %v", status.Name, expected.duration, result.Duration)
}
delete(expectedEndpoints, status.Name)
}
if len(expectedEndpoints) > 0 {
t.Errorf("missing expected endpoints: %v", expectedEndpoints)
}
})
// Test 4: GetAllSuiteStatuses should only return suites, not endpoints
t.Run("GetAllSuiteStatuses", func(t *testing.T) {
store, endpoint1, _, _, _, testSuite := setupStore(t)
// InsertEndpointResult test data
store.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})
timestamp := time.Now()
store.InsertSuiteResult(testSuite, &suite.Result{
Name: testSuite.Name, Group: testSuite.Group, Success: true,
Timestamp: timestamp, Duration: 125 * time.Millisecond,
})
statuses, err := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
if err != nil {
t.Fatalf("failed to get all suite statuses: %v", err)
}
// Should have 1 suite
if len(statuses) != 1 {
t.Errorf("expected 1 suite status, got %d", len(statuses))
}
if len(statuses) > 0 {
suiteStatus := statuses[0]
if suiteStatus.Name != "test-suite" {
t.Errorf("expected suite name 'test-suite', got '%s'", suiteStatus.Name)
}
if suiteStatus.Group != "suite-group" {
t.Errorf("expected suite group 'suite-group', got '%s'", suiteStatus.Group)
}
if len(suiteStatus.Results) != 1 {
t.Errorf("expected 1 suite result, got %d", len(suiteStatus.Results))
}
if len(suiteStatus.Results) > 0 {
result := suiteStatus.Results[0]
if !result.Success {
t.Error("expected suite result to be successful")
}
if result.Duration != 125*time.Millisecond {
t.Errorf("expected suite result duration 125ms, got %v", result.Duration)
}
}
}
})
// Test 5: GetEndpointStatusByKey should work for all endpoints
t.Run("GetEndpointStatusByKey", func(t *testing.T) {
store, endpoint1, _, suiteEndpoint1, _, _ := setupStore(t)
// InsertEndpointResult test data with specific timestamps and durations
timestamp1 := time.Now()
timestamp2 := time.Now().Add(1 * time.Hour)
store.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: timestamp1, Duration: 100 * time.Millisecond})
store.InsertEndpointResult(suiteEndpoint1, &endpoint.Result{Success: false, Timestamp: timestamp2, Duration: 50 * time.Millisecond, Errors: []string{"suite error"}})
// Test regular endpoints
status1, err := store.GetEndpointStatusByKey(endpoint1.Key(), &paging.EndpointStatusParams{})
if err != nil {
t.Fatalf("failed to get endpoint1 status: %v", err)
}
if status1.Name != "endpoint1" {
t.Errorf("expected endpoint1, got %s", status1.Name)
}
if status1.Group != "group1" {
t.Errorf("expected group1, got %s", status1.Group)
}
if len(status1.Results) != 1 {
t.Errorf("expected 1 result for endpoint1, got %d", len(status1.Results))
}
if len(status1.Results) > 0 {
result := status1.Results[0]
if !result.Success {
t.Error("expected endpoint1 result to be successful")
}
if result.Duration != 100*time.Millisecond {
t.Errorf("expected endpoint1 result duration 100ms, got %v", result.Duration)
}
}
// Test suite endpoints
suiteStatus1, err := store.GetEndpointStatusByKey(suiteEndpoint1.Key(), &paging.EndpointStatusParams{})
if err != nil {
t.Fatalf("failed to get suite endpoint1 status: %v", err)
}
if suiteStatus1.Name != "suite-endpoint1" {
t.Errorf("expected suite-endpoint1, got %s", suiteStatus1.Name)
}
if suiteStatus1.Group != "suite-group" {
t.Errorf("expected suite-group, got %s", suiteStatus1.Group)
}
if len(suiteStatus1.Results) != 1 {
t.Errorf("expected 1 result for suite-endpoint1, got %d", len(suiteStatus1.Results))
}
if len(suiteStatus1.Results) > 0 {
result := suiteStatus1.Results[0]
if result.Success {
t.Error("expected suite-endpoint1 result to be unsuccessful")
}
if result.Duration != 50*time.Millisecond {
t.Errorf("expected suite-endpoint1 result duration 50ms, got %v", result.Duration)
}
if len(result.Errors) != 1 || result.Errors[0] != "suite error" {
t.Errorf("expected suite-endpoint1 to have error 'suite error', got %v", result.Errors)
}
}
})
// Test 6: GetSuiteStatusByKey should work for suites
t.Run("GetSuiteStatusByKey", func(t *testing.T) {
store, _, _, _, _, testSuite := setupStore(t)
// InsertEndpointResult suite result with endpoint results
timestamp := time.Now()
store.InsertSuiteResult(testSuite, &suite.Result{
Name: testSuite.Name, Group: testSuite.Group, Success: false,
Timestamp: timestamp, Duration: 125 * time.Millisecond,
EndpointResults: []*endpoint.Result{
{Success: true, Duration: 50 * time.Millisecond},
{Success: false, Duration: 75 * time.Millisecond, Errors: []string{"endpoint failed"}},
},
})
suiteStatus, err := store.GetSuiteStatusByKey(testSuite.Key(), &paging.SuiteStatusParams{})
if err != nil {
t.Fatalf("failed to get suite status: %v", err)
}
if suiteStatus.Name != "test-suite" {
t.Errorf("expected test-suite, got %s", suiteStatus.Name)
}
if suiteStatus.Group != "suite-group" {
t.Errorf("expected suite-group, got %s", suiteStatus.Group)
}
if len(suiteStatus.Results) != 1 {
t.Errorf("expected 1 suite result, got %d", len(suiteStatus.Results))
}
if len(suiteStatus.Results) > 0 {
result := suiteStatus.Results[0]
if result.Success {
t.Error("expected suite result to be unsuccessful")
}
if result.Duration != 125*time.Millisecond {
t.Errorf("expected suite result duration 125ms, got %v", result.Duration)
}
if len(result.EndpointResults) != 2 {
t.Errorf("expected 2 endpoint results, got %d", len(result.EndpointResults))
}
if len(result.EndpointResults) >= 2 {
if !result.EndpointResults[0].Success {
t.Error("expected first endpoint result to be successful")
}
if result.EndpointResults[1].Success {
t.Error("expected second endpoint result to be unsuccessful")
}
if len(result.EndpointResults[1].Errors) != 1 || result.EndpointResults[1].Errors[0] != "endpoint failed" {
t.Errorf("expected second endpoint to have error 'endpoint failed', got %v", result.EndpointResults[1].Errors)
}
}
}
})
// Test 7: DeleteAllEndpointStatusesNotInKeys should not affect suites
t.Run("DeleteEndpointsNotInKeys", func(t *testing.T) {
store, endpoint1, endpoint2, suiteEndpoint1, suiteEndpoint2, testSuite := setupStore(t)
// InsertEndpointResult all test data
store.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})
store.InsertEndpointResult(endpoint2, &endpoint.Result{Success: false, Timestamp: time.Now(), Duration: 200 * time.Millisecond})
store.InsertEndpointResult(suiteEndpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 50 * time.Millisecond})
store.InsertEndpointResult(suiteEndpoint2, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 75 * time.Millisecond})
store.InsertSuiteResult(testSuite, &suite.Result{
Name: testSuite.Name, Group: testSuite.Group, Success: true,
Timestamp: time.Now(), Duration: 125 * time.Millisecond,
})
// Keep only endpoint1 and suite-endpoint1
keysToKeep := []string{endpoint1.Key(), suiteEndpoint1.Key()}
deleted := store.DeleteAllEndpointStatusesNotInKeys(keysToKeep)
// Should have deleted 2 endpoints (endpoint2 and suite-endpoint2)
if deleted != 2 {
t.Errorf("expected to delete 2 endpoints, deleted %d", deleted)
}
// Verify remaining endpoints
statuses, _ := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
if len(statuses) != 2 {
t.Errorf("expected 2 remaining endpoint statuses, got %d", len(statuses))
}
// Suite should still exist
suiteStatuses, _ := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
if len(suiteStatuses) != 1 {
t.Errorf("suite should not be affected by DeleteAllEndpointStatusesNotInKeys")
}
})
// Test 8: DeleteAllSuiteStatusesNotInKeys should not affect endpoints
t.Run("DeleteSuitesNotInKeys", func(t *testing.T) {
store, endpoint1, _, _, _, testSuite := setupStore(t)
// InsertEndpointResult test data
store.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})
store.InsertSuiteResult(testSuite, &suite.Result{
Name: testSuite.Name, Group: testSuite.Group, Success: true,
Timestamp: time.Now(), Duration: 125 * time.Millisecond,
})
// First, add another suite to test deletion
anotherSuite := &suite.Suite{
Name: "another-suite",
Group: "another-group",
}
anotherSuiteResult := &suite.Result{
Name: anotherSuite.Name,
Group: anotherSuite.Group,
Success: true,
Timestamp: time.Now(),
Duration: 100 * time.Millisecond,
}
store.InsertSuiteResult(anotherSuite, anotherSuiteResult)
// Keep only the original test-suite
deleted := store.DeleteAllSuiteStatusesNotInKeys([]string{testSuite.Key()})
// Should have deleted 1 suite (another-suite)
if deleted != 1 {
t.Errorf("expected to delete 1 suite, deleted %d", deleted)
}
// Endpoints should still exist
endpointStatuses, _ := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
if len(endpointStatuses) != 1 {
t.Errorf("endpoints should not be affected by DeleteAllSuiteStatusesNotInKeys")
}
// Only one suite should remain
suiteStatuses, _ := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
if len(suiteStatuses) != 1 {
t.Errorf("expected 1 remaining suite, got %d", len(suiteStatuses))
}
})
// Test 9: Clear should remove everything
t.Run("Clear", func(t *testing.T) {
store, endpoint1, _, _, _, testSuite := setupStore(t)
// InsertEndpointResult test data
store.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})
store.InsertSuiteResult(testSuite, &suite.Result{
Name: testSuite.Name, Group: testSuite.Group, Success: true,
Timestamp: time.Now(), Duration: 125 * time.Millisecond,
})
store.Clear()
// No endpoints should remain
endpointStatuses, _ := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
if len(endpointStatuses) != 0 {
t.Errorf("expected 0 endpoints after clear, got %d", len(endpointStatuses))
}
// No suites should remain
suiteStatuses, _ := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
if len(suiteStatuses) != 0 {
t.Errorf("expected 0 suites after clear, got %d", len(suiteStatuses))
}
})
}
// TestStore_EndpointStatusCastingSafety tests that type assertions are safe
func TestStore_EndpointStatusCastingSafety(t *testing.T) {
store, err := NewStore(100, 50)
if err != nil {
t.Fatal("expected no error, got", err)
}
// InsertEndpointResult an endpoint
ep := &endpoint.Endpoint{
Name: "test-endpoint",
Group: "test",
URL: "https://example.com",
}
result := &endpoint.Result{
Success: true,
Timestamp: time.Now(),
Duration: 100 * time.Millisecond,
}
store.InsertEndpointResult(ep, result)
// InsertEndpointResult a suite
testSuite := &suite.Suite{
Name: "test-suite",
Group: "test",
}
suiteResult := &suite.Result{
Name: testSuite.Name,
Group: testSuite.Group,
Success: true,
Timestamp: time.Now(),
Duration: 200 * time.Millisecond,
}
store.InsertSuiteResult(testSuite, suiteResult)
// This should not panic even with mixed types in cache
statuses, err := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
if err != nil {
t.Fatalf("failed to get all endpoint statuses: %v", err)
}
// Should only have the endpoint, not the suite
if len(statuses) != 1 {
t.Errorf("expected 1 endpoint status, got %d", len(statuses))
}
if statuses[0].Name != "test-endpoint" {
t.Errorf("expected test-endpoint, got %s", statuses[0].Name)
}
}
func TestStore_MaximumLimits(t *testing.T) {
// Use small limits to test trimming behavior
maxResults := 5
maxEvents := 3
store, err := NewStore(maxResults, maxEvents)
if err != nil {
t.Fatal("expected no error, got", err)
}
defer store.Clear()
t.Run("endpoint-result-limits", func(t *testing.T) {
ep := &endpoint.Endpoint{Name: "test-endpoint", Group: "test", URL: "https://example.com"}
// Insert more results than the maximum
baseTime := time.Now().Add(-10 * time.Hour)
for i := 0; i < maxResults*2; i++ {
result := &endpoint.Result{
Success: i%2 == 0,
Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
Duration: time.Duration(i*10) * time.Millisecond,
}
err := store.InsertEndpointResult(ep, result)
if err != nil {
t.Fatalf("failed to insert result %d: %v", i, err)
}
}
// Verify only maxResults are kept
status, err := store.GetEndpointStatusByKey(ep.Key(), nil)
if err != nil {
t.Fatalf("failed to get endpoint status: %v", err)
}
if len(status.Results) != maxResults {
t.Errorf("expected %d results after trimming, got %d", maxResults, len(status.Results))
}
// Verify the newest results are kept (should be results 5-9, not 0-4)
if len(status.Results) > 0 {
firstResult := status.Results[0]
lastResult := status.Results[len(status.Results)-1]
// First result should be older than last result due to append order
if !lastResult.Timestamp.After(firstResult.Timestamp) {
t.Error("expected results to be in chronological order")
}
// The last result should be the most recent one we inserted
expectedLastDuration := time.Duration((maxResults*2-1)*10) * time.Millisecond
if lastResult.Duration != expectedLastDuration {
t.Errorf("expected last result duration %v, got %v", expectedLastDuration, lastResult.Duration)
}
}
})
t.Run("suite-result-limits", func(t *testing.T) {
testSuite := &suite.Suite{Name: "test-suite", Group: "test"}
// Insert more results than the maximum
baseTime := time.Now().Add(-10 * time.Hour)
for i := 0; i < maxResults*2; i++ {
result := &suite.Result{
Name: testSuite.Name,
Group: testSuite.Group,
Success: i%2 == 0,
Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
Duration: time.Duration(i*10) * time.Millisecond,
}
err := store.InsertSuiteResult(testSuite, result)
if err != nil {
t.Fatalf("failed to insert suite result %d: %v", i, err)
}
}
// Verify only maxResults are kept
status, err := store.GetSuiteStatusByKey(testSuite.Key(), &paging.SuiteStatusParams{})
if err != nil {
t.Fatalf("failed to get suite status: %v", err)
}
if len(status.Results) != maxResults {
t.Errorf("expected %d results after trimming, got %d", maxResults, len(status.Results))
}
// Verify the newest results are kept (should be results 5-9, not 0-4)
if len(status.Results) > 0 {
firstResult := status.Results[0]
lastResult := status.Results[len(status.Results)-1]
// First result should be older than last result due to append order
if !lastResult.Timestamp.After(firstResult.Timestamp) {
t.Error("expected results to be in chronological order")
}
// The last result should be the most recent one we inserted
expectedLastDuration := time.Duration((maxResults*2-1)*10) * time.Millisecond
if lastResult.Duration != expectedLastDuration {
t.Errorf("expected last result duration %v, got %v", expectedLastDuration, lastResult.Duration)
}
}
})
}
func TestSuiteResultOrdering(t *testing.T) {
store, err := NewStore(10, 5)
if err != nil {
t.Fatal("expected no error, got", err)
}
defer store.Clear()
testSuite := &suite.Suite{Name: "ordering-suite", Group: "test"}
// Insert results with distinct timestamps
baseTime := time.Now().Add(-5 * time.Hour)
timestamps := make([]time.Time, 5)
for i := range 5 {
timestamp := baseTime.Add(time.Duration(i) * time.Hour)
timestamps[i] = timestamp
result := &suite.Result{
Name: testSuite.Name,
Group: testSuite.Group,
Success: true,
Timestamp: timestamp,
Duration: time.Duration(i*100) * time.Millisecond,
}
err := store.InsertSuiteResult(testSuite, result)
if err != nil {
t.Fatalf("failed to insert result %d: %v", i, err)
}
}
t.Run("chronological-append-order", func(t *testing.T) {
status, err := store.GetSuiteStatusByKey(testSuite.Key(), nil)
if err != nil {
t.Fatalf("failed to get suite status: %v", err)
}
// Verify results are in chronological order (oldest first due to append)
for i := 0; i < len(status.Results)-1; i++ {
current := status.Results[i]
next := status.Results[i+1]
if !next.Timestamp.After(current.Timestamp) {
t.Errorf("result %d timestamp %v should be before result %d timestamp %v",
i, current.Timestamp, i+1, next.Timestamp)
}
}
// Verify specific timestamp order
if !status.Results[0].Timestamp.Equal(timestamps[0]) {
t.Errorf("first result timestamp should be %v, got %v", timestamps[0], status.Results[0].Timestamp)
}
if !status.Results[len(status.Results)-1].Timestamp.Equal(timestamps[len(timestamps)-1]) {
t.Errorf("last result timestamp should be %v, got %v", timestamps[len(timestamps)-1], status.Results[len(status.Results)-1].Timestamp)
}
})
t.Run("pagination-newest-first", func(t *testing.T) {
// Test reverse pagination (newest first in paginated results)
page1 := ShallowCopySuiteStatus(
&suite.Status{
Name: testSuite.Name, Group: testSuite.Group, Key: testSuite.Key(),
Results: []*suite.Result{
{Timestamp: timestamps[0], Duration: 0 * time.Millisecond},
{Timestamp: timestamps[1], Duration: 100 * time.Millisecond},
{Timestamp: timestamps[2], Duration: 200 * time.Millisecond},
{Timestamp: timestamps[3], Duration: 300 * time.Millisecond},
{Timestamp: timestamps[4], Duration: 400 * time.Millisecond},
},
},
paging.NewSuiteStatusParams().WithPagination(1, 3),
)
if len(page1.Results) != 3 {
t.Errorf("expected 3 results in page 1, got %d", len(page1.Results))
}
// With reverse pagination, page 1 should have the 3 newest results
// That means results[2], results[3], results[4] from original array
if page1.Results[0].Duration != 200*time.Millisecond {
t.Errorf("expected first result in page to have 200ms duration, got %v", page1.Results[0].Duration)
}
if page1.Results[2].Duration != 400*time.Millisecond {
t.Errorf("expected last result in page to have 400ms duration, got %v", page1.Results[2].Duration)
}
})
t.Run("trimming-preserves-newest", func(t *testing.T) {
limitedStore, err := NewStore(3, 2) // Very small limits
if err != nil {
t.Fatal("expected no error, got", err)
}
defer limitedStore.Clear()
smallSuite := &suite.Suite{Name: "small-suite", Group: "test"}
// Insert 6 results, should keep only the newest 3
for i := range 6 {
result := &suite.Result{
Name: smallSuite.Name,
Group: smallSuite.Group,
Success: true,
Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
Duration: time.Duration(i*50) * time.Millisecond,
}
err := limitedStore.InsertSuiteResult(smallSuite, result)
if err != nil {
t.Fatalf("failed to insert result %d: %v", i, err)
}
}
status, err := limitedStore.GetSuiteStatusByKey(smallSuite.Key(), nil)
if err != nil {
t.Fatalf("failed to get suite status: %v", err)
}
if len(status.Results) != 3 {
t.Errorf("expected 3 results after trimming, got %d", len(status.Results))
}
// Should have results 3, 4, 5 (the newest ones)
expectedDurations := []time.Duration{150 * time.Millisecond, 200 * time.Millisecond, 250 * time.Millisecond}
for i, expectedDuration := range expectedDurations {
if status.Results[i].Duration != expectedDuration {
t.Errorf("result %d should have duration %v, got %v", i, expectedDuration, status.Results[i].Duration)
}
}
})
}
func TestStore_ConcurrentAccess(t *testing.T) {
store, err := NewStore(100, 50)
if err != nil {
t.Fatal("expected no error, got", err)
}
defer store.Clear()
t.Run("concurrent-endpoint-insertions", func(t *testing.T) {
var wg sync.WaitGroup
numGoroutines := 10
resultsPerGoroutine := 5
// Create endpoints for concurrent testing
endpoints := make([]*endpoint.Endpoint, numGoroutines)
for i := range numGoroutines {
endpoints[i] = &endpoint.Endpoint{
Name: "endpoint-" + string(rune('A'+i)),
Group: "concurrent",
URL: "https://example.com/" + string(rune('A'+i)),
}
}
// Concurrently insert results for different endpoints
for i := range numGoroutines {
wg.Add(1)
go func(endpointIndex int) {
defer wg.Done()
ep := endpoints[endpointIndex]
for j := range resultsPerGoroutine {
result := &endpoint.Result{
Success: j%2 == 0,
Timestamp: time.Now().Add(time.Duration(j) * time.Minute),
Duration: time.Duration(j*10) * time.Millisecond,
}
if err := store.InsertEndpointResult(ep, result); err != nil {
t.Errorf("failed to insert result for endpoint %d: %v", endpointIndex, err)
}
}
}(i)
}
wg.Wait()
// Verify all endpoints were created and have correct result counts
statuses, err := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
if err != nil {
t.Fatalf("failed to get all endpoint statuses: %v", err)
}
if len(statuses) != numGoroutines {
t.Errorf("expected %d endpoint statuses, got %d", numGoroutines, len(statuses))
}
// Verify each endpoint has the correct number of results
for _, status := range statuses {
if len(status.Results) != resultsPerGoroutine {
t.Errorf("endpoint %s should have %d results, got %d", status.Name, resultsPerGoroutine, len(status.Results))
}
}
})
t.Run("concurrent-suite-insertions", func(t *testing.T) {
var wg sync.WaitGroup
numGoroutines := 5
resultsPerGoroutine := 3
// Create suites for concurrent testing
suites := make([]*suite.Suite, numGoroutines)
for i := range numGoroutines {
suites[i] = &suite.Suite{
Name: "suite-" + string(rune('A'+i)),
Group: "concurrent",
}
}
// Concurrently insert results for different suites
for i := range numGoroutines {
wg.Add(1)
go func(suiteIndex int) {
defer wg.Done()
su := suites[suiteIndex]
for j := range resultsPerGoroutine {
result := &suite.Result{
Name: su.Name,
Group: su.Group,
Success: j%2 == 0,
Timestamp: time.Now().Add(time.Duration(j) * time.Minute),
Duration: time.Duration(j*50) * time.Millisecond,
}
if err := store.InsertSuiteResult(su, result); err != nil {
t.Errorf("failed to insert result for suite %d: %v", suiteIndex, err)
}
}
}(i)
}
wg.Wait()
// Verify all suites were created and have correct result counts
statuses, err := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
if err != nil {
t.Fatalf("failed to get all suite statuses: %v", err)
}
if len(statuses) != numGoroutines {
t.Errorf("expected %d suite statuses, got %d", numGoroutines, len(statuses))
}
// Verify each suite has the correct number of results
for _, status := range statuses {
if len(status.Results) != resultsPerGoroutine {
t.Errorf("suite %s should have %d results, got %d", status.Name, resultsPerGoroutine, len(status.Results))
}
}
})
t.Run("concurrent-mixed-operations", func(t *testing.T) {
var wg sync.WaitGroup
// Setup test data
ep := &endpoint.Endpoint{Name: "mixed-endpoint", Group: "test", URL: "https://example.com"}
testSuite := &suite.Suite{Name: "mixed-suite", Group: "test"}
// Concurrent endpoint insertions
wg.Add(1)
go func() {
defer wg.Done()
for i := range 5 {
result := &endpoint.Result{
Success: true,
Timestamp: time.Now(),
Duration: time.Duration(i*10) * time.Millisecond,
}
store.InsertEndpointResult(ep, result)
}
}()
// Concurrent suite insertions
wg.Add(1)
go func() {
defer wg.Done()
for i := range 5 {
result := &suite.Result{
Name: testSuite.Name,
Group: testSuite.Group,
Success: true,
Timestamp: time.Now(),
Duration: time.Duration(i*20) * time.Millisecond,
}
store.InsertSuiteResult(testSuite, result)
}
}()
// Concurrent reads
wg.Add(1)
go func() {
defer wg.Done()
for range 10 {
store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
time.Sleep(1 * time.Millisecond)
}
}()
wg.Wait()
// Verify final state is consistent
endpointStatuses, err := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})
if err != nil {
t.Fatalf("failed to get endpoint statuses after concurrent operations: %v", err)
}
if len(endpointStatuses) == 0 {
t.Error("expected at least one endpoint status after concurrent operations")
}
suiteStatuses, err := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})
if err != nil {
t.Fatalf("failed to get suite statuses after concurrent operations: %v", err)
}
if len(suiteStatuses) == 0 {
t.Error("expected at least one suite status after concurrent operations")
}
})
}
================================================
FILE: storage/store/memory/uptime.go
================================================
package memory
import (
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
)
const (
uptimeCleanUpThreshold = 32 * 24
uptimeRetention = 30 * 24 * time.Hour
)
// processUptimeAfterResult processes the result by extracting the relevant from the result and recalculating the uptime
// if necessary
func processUptimeAfterResult(uptime *endpoint.Uptime, result *endpoint.Result) {
if uptime.HourlyStatistics == nil {
uptime.HourlyStatistics = make(map[int64]*endpoint.HourlyUptimeStatistics)
}
unixTimestampFlooredAtHour := result.Timestamp.Truncate(time.Hour).Unix()
hourlyStats, _ := uptime.HourlyStatistics[unixTimestampFlooredAtHour]
if hourlyStats == nil {
hourlyStats = &endpoint.HourlyUptimeStatistics{}
uptime.HourlyStatistics[unixTimestampFlooredAtHour] = hourlyStats
}
if result.Success {
hourlyStats.SuccessfulExecutions++
}
hourlyStats.TotalExecutions++
hourlyStats.TotalExecutionsResponseTime += uint64(result.Duration.Milliseconds())
// Clean up only when we're starting to have too many useless keys
// Note that this is only triggered when there are more entries than there should be after
// 32 days, despite the fact that we are deleting everything that's older than 30 days.
// This is to prevent re-iterating on every `processUptimeAfterResult` as soon as the uptime has been logged for 30 days.
if len(uptime.HourlyStatistics) > uptimeCleanUpThreshold {
sevenDaysAgo := time.Now().Add(-(uptimeRetention + time.Hour)).Unix()
for hourlyUnixTimestamp := range uptime.HourlyStatistics {
if sevenDaysAgo > hourlyUnixTimestamp {
delete(uptime.HourlyStatistics, hourlyUnixTimestamp)
}
}
}
}
================================================
FILE: storage/store/memory/uptime_bench_test.go
================================================
package memory
import (
"testing"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func BenchmarkProcessUptimeAfterResult(b *testing.B) {
uptime := endpoint.NewUptime()
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
// Start 12000 days ago
timestamp := now.Add(-12000 * 24 * time.Hour)
for n := 0; n < b.N; n++ {
processUptimeAfterResult(uptime, &endpoint.Result{
Duration: 18 * time.Millisecond,
Success: n%15 == 0,
Timestamp: timestamp,
})
// Simulate an endpoint with an interval of 3 minutes
timestamp = timestamp.Add(3 * time.Minute)
}
b.ReportAllocs()
}
================================================
FILE: storage/store/memory/uptime_test.go
================================================
package memory
import (
"testing"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage"
)
func TestProcessUptimeAfterResult(t *testing.T) {
ep := &endpoint.Endpoint{Name: "name", Group: "group"}
status := endpoint.NewStatus(ep.Group, ep.Name)
uptime := status.Uptime
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-7 * 24 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-6 * 24 * time.Hour), Success: false})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-8 * 24 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-24 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-12 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-1 * time.Hour), Success: true, Duration: 10 * time.Millisecond})
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 10, 1, 1)
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-30 * time.Minute), Success: false, Duration: 500 * time.Millisecond})
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 510, 2, 1)
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-15 * time.Minute), Success: false, Duration: 25 * time.Millisecond})
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 535, 3, 1)
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-10 * time.Minute), Success: false})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-120 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-119 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-118 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-117 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-10 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-8 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-30 * time.Minute), Success: true})
processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-25 * time.Minute), Success: true})
}
func TestAddResultUptimeIsCleaningUpAfterItself(t *testing.T) {
ep := &endpoint.Endpoint{Name: "name", Group: "group"}
status := endpoint.NewStatus(ep.Group, ep.Name)
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
// Start 12 days ago
timestamp := now.Add(-12 * 24 * time.Hour)
for timestamp.Unix() <= now.Unix() {
AddResult(status, &endpoint.Result{Timestamp: timestamp, Success: true}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if len(status.Uptime.HourlyStatistics) > uptimeCleanUpThreshold {
t.Errorf("At no point in time should there be more than %d entries in status.SuccessfulExecutionsPerHour, but there are %d", uptimeCleanUpThreshold, len(status.Uptime.HourlyStatistics))
}
// Simulate endpoint with an interval of 3 minutes
timestamp = timestamp.Add(3 * time.Minute)
}
}
func checkHourlyStatistics(t *testing.T, hourlyUptimeStatistics *endpoint.HourlyUptimeStatistics, expectedTotalExecutionsResponseTime uint64, expectedTotalExecutions uint64, expectedSuccessfulExecutions uint64) {
if hourlyUptimeStatistics.TotalExecutionsResponseTime != expectedTotalExecutionsResponseTime {
t.Error("TotalExecutionsResponseTime should've been", expectedTotalExecutionsResponseTime, "got", hourlyUptimeStatistics.TotalExecutionsResponseTime)
}
if hourlyUptimeStatistics.TotalExecutions != expectedTotalExecutions {
t.Error("TotalExecutions should've been", expectedTotalExecutions, "got", hourlyUptimeStatistics.TotalExecutions)
}
if hourlyUptimeStatistics.SuccessfulExecutions != expectedSuccessfulExecutions {
t.Error("SuccessfulExecutions should've been", expectedSuccessfulExecutions, "got", hourlyUptimeStatistics.SuccessfulExecutions)
}
}
================================================
FILE: storage/store/memory/util.go
================================================
package memory
import (
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
)
// ShallowCopyEndpointStatus returns a shallow copy of a Status with only the results
// within the range defined by the page and pageSize parameters
func ShallowCopyEndpointStatus(ss *endpoint.Status, params *paging.EndpointStatusParams) *endpoint.Status {
shallowCopy := &endpoint.Status{
Name: ss.Name,
Group: ss.Group,
Key: ss.Key,
Uptime: endpoint.NewUptime(),
}
if params == nil || (params.ResultsPage == 0 && params.ResultsPageSize == 0 && params.EventsPage == 0 && params.EventsPageSize == 0) {
shallowCopy.Results = ss.Results
shallowCopy.Events = ss.Events
} else {
numberOfResults := len(ss.Results)
resultsStart, resultsEnd := getStartAndEndIndex(numberOfResults, params.ResultsPage, params.ResultsPageSize)
if resultsStart < 0 || resultsEnd < 0 {
shallowCopy.Results = []*endpoint.Result{}
} else {
shallowCopy.Results = ss.Results[resultsStart:resultsEnd]
}
numberOfEvents := len(ss.Events)
eventsStart, eventsEnd := getStartAndEndIndex(numberOfEvents, params.EventsPage, params.EventsPageSize)
if eventsStart < 0 || eventsEnd < 0 {
shallowCopy.Events = []*endpoint.Event{}
} else {
shallowCopy.Events = ss.Events[eventsStart:eventsEnd]
}
}
return shallowCopy
}
// ShallowCopySuiteStatus returns a shallow copy of a suite Status with only the results
// within the range defined by the page and pageSize parameters
func ShallowCopySuiteStatus(ss *suite.Status, params *paging.SuiteStatusParams) *suite.Status {
shallowCopy := &suite.Status{
Name: ss.Name,
Group: ss.Group,
Key: ss.Key,
}
if params == nil || (params.Page == 0 && params.PageSize == 0) {
shallowCopy.Results = ss.Results
} else {
numberOfResults := len(ss.Results)
resultsStart, resultsEnd := getStartAndEndIndex(numberOfResults, params.Page, params.PageSize)
if resultsStart < 0 || resultsEnd < 0 {
shallowCopy.Results = []*suite.Result{}
} else {
shallowCopy.Results = ss.Results[resultsStart:resultsEnd]
}
}
return shallowCopy
}
func getStartAndEndIndex(numberOfResults int, page, pageSize int) (int, int) {
if page < 1 || pageSize < 0 {
return -1, -1
}
start := numberOfResults - (page * pageSize)
end := numberOfResults - ((page - 1) * pageSize)
if start > numberOfResults {
start = -1
} else if start < 0 {
start = 0
}
if end > numberOfResults {
end = numberOfResults
}
return start, end
}
// AddResult adds a Result to Status.Results and makes sure that there are
// no more than MaximumNumberOfResults results in the Results slice
func AddResult(ss *endpoint.Status, result *endpoint.Result, maximumNumberOfResults, maximumNumberOfEvents int) {
if ss == nil {
return
}
if len(ss.Results) > 0 {
// Check if there's any change since the last result
if ss.Results[len(ss.Results)-1].Success != result.Success {
ss.Events = append(ss.Events, endpoint.NewEventFromResult(result))
if len(ss.Events) > maximumNumberOfEvents {
// Doing ss.Events[1:] would usually be sufficient, but in the case where for some reason, the slice has
// more than one extra element, we can get rid of all of them at once and thus returning the slice to a
// length of MaximumNumberOfEvents by using ss.Events[len(ss.Events)-MaximumNumberOfEvents:] instead
ss.Events = ss.Events[len(ss.Events)-maximumNumberOfEvents:]
}
}
} else {
// This is the first result, so we need to add the first healthy/unhealthy event
ss.Events = append(ss.Events, endpoint.NewEventFromResult(result))
}
ss.Results = append(ss.Results, result)
if len(ss.Results) > maximumNumberOfResults {
// Doing ss.Results[1:] would usually be sufficient, but in the case where for some reason, the slice has more
// than one extra element, we can get rid of all of them at once and thus returning the slice to a length of
// MaximumNumberOfResults by using ss.Results[len(ss.Results)-MaximumNumberOfResults:] instead
ss.Results = ss.Results[len(ss.Results)-maximumNumberOfResults:]
}
processUptimeAfterResult(ss.Uptime, result)
}
================================================
FILE: storage/store/memory/util_bench_test.go
================================================
package memory
import (
"testing"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
)
func BenchmarkShallowCopyEndpointStatus(b *testing.B) {
ep := &testEndpoint
status := endpoint.NewStatus(ep.Group, ep.Name)
for range storage.DefaultMaximumNumberOfResults {
AddResult(status, &testSuccessfulResult, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
}
for b.Loop() {
ShallowCopyEndpointStatus(status, paging.NewEndpointStatusParams().WithResults(1, 20))
}
b.ReportAllocs()
}
================================================
FILE: storage/store/memory/util_test.go
================================================
package memory
import (
"testing"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
)
func TestAddResult(t *testing.T) {
ep := &endpoint.Endpoint{Name: "name", Group: "group"}
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
for i := range (storage.DefaultMaximumNumberOfResults + storage.DefaultMaximumNumberOfEvents) * 2 {
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
}
if len(endpointStatus.Results) != storage.DefaultMaximumNumberOfResults {
t.Errorf("expected endpointStatus.Results to not exceed a length of %d", storage.DefaultMaximumNumberOfResults)
}
if len(endpointStatus.Events) != storage.DefaultMaximumNumberOfEvents {
t.Errorf("expected endpointStatus.Events to not exceed a length of %d", storage.DefaultMaximumNumberOfEvents)
}
// Try to add nil endpointStatus
AddResult(nil, &endpoint.Result{Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
}
func TestShallowCopyEndpointStatus(t *testing.T) {
ep := &endpoint.Endpoint{Name: "name", Group: "group"}
endpointStatus := endpoint.NewStatus(ep.Group, ep.Name)
ts := time.Now().Add(-25 * time.Hour)
for i := range 25 {
AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: ts}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
ts = ts.Add(time.Hour)
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(-1, -1)).Results) != 0 {
t.Error("expected to have 0 result")
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(1, 1)).Results) != 1 {
t.Error("expected to have 1 result")
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(5, 0)).Results) != 0 {
t.Error("expected to have 0 results")
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(-1, 20)).Results) != 0 {
t.Error("expected to have 0 result, because the page was invalid")
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(1, -1)).Results) != 0 {
t.Error("expected to have 0 result, because the page size was invalid")
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(1, 10)).Results) != 10 {
t.Error("expected to have 10 results, because given a page size of 10, page 1 should have 10 elements")
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(2, 10)).Results) != 10 {
t.Error("expected to have 10 results, because given a page size of 10, page 2 should have 10 elements")
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(3, 10)).Results) != 5 {
t.Error("expected to have 5 results, because given a page size of 10, page 3 should have 5 elements")
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(4, 10)).Results) != 0 {
t.Error("expected to have 0 results, because given a page size of 10, page 4 should have 0 elements")
}
if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(1, 50)).Results) != 25 {
t.Error("expected to have 25 results, because there's only 25 results")
}
}
func TestShallowCopySuiteStatus(t *testing.T) {
testSuite := &suite.Suite{Name: "test-suite", Group: "test-group"}
suiteStatus := &suite.Status{
Name: testSuite.Name,
Group: testSuite.Group,
Key: testSuite.Key(),
Results: []*suite.Result{},
}
ts := time.Now().Add(-25 * time.Hour)
for i := range 25 {
result := &suite.Result{
Name: testSuite.Name,
Group: testSuite.Group,
Success: i%2 == 0,
Timestamp: ts,
Duration: time.Duration(i*10) * time.Millisecond,
}
suiteStatus.Results = append(suiteStatus.Results, result)
ts = ts.Add(time.Hour)
}
t.Run("invalid-page-negative", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(-1, 10))
if len(result.Results) != 0 {
t.Errorf("expected 0 results for negative page, got %d", len(result.Results))
}
})
t.Run("invalid-page-zero", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(0, 10))
if len(result.Results) != 0 {
t.Errorf("expected 0 results for zero page, got %d", len(result.Results))
}
})
t.Run("invalid-pagesize-negative", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(1, -1))
if len(result.Results) != 0 {
t.Errorf("expected 0 results for negative page size, got %d", len(result.Results))
}
})
t.Run("zero-pagesize", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(1, 0))
if len(result.Results) != 0 {
t.Errorf("expected 0 results for zero page size, got %d", len(result.Results))
}
})
t.Run("nil-params", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, nil)
if len(result.Results) != 25 {
t.Errorf("expected 25 results for nil params, got %d", len(result.Results))
}
})
t.Run("zero-params", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, &paging.SuiteStatusParams{Page: 0, PageSize: 0})
if len(result.Results) != 25 {
t.Errorf("expected 25 results for zero-value params, got %d", len(result.Results))
}
})
t.Run("first-page", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(1, 10))
if len(result.Results) != 10 {
t.Errorf("expected 10 results for page 1, size 10, got %d", len(result.Results))
}
// Verify newest results are returned (reverse pagination)
if len(result.Results) > 0 && !result.Results[len(result.Results)-1].Timestamp.After(result.Results[0].Timestamp) {
t.Error("expected newest result to be at the end")
}
})
t.Run("second-page", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(2, 10))
if len(result.Results) != 10 {
t.Errorf("expected 10 results for page 2, size 10, got %d", len(result.Results))
}
})
t.Run("last-partial-page", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(3, 10))
if len(result.Results) != 5 {
t.Errorf("expected 5 results for page 3, size 10, got %d", len(result.Results))
}
})
t.Run("beyond-available-pages", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(4, 10))
if len(result.Results) != 0 {
t.Errorf("expected 0 results for page beyond available data, got %d", len(result.Results))
}
})
t.Run("large-page-size", func(t *testing.T) {
result := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(1, 100))
if len(result.Results) != 25 {
t.Errorf("expected 25 results for large page size, got %d", len(result.Results))
}
})
}
================================================
FILE: storage/store/sql/specific_postgres.go
================================================
package sql
func (s *Store) createPostgresSchema() error {
// Create suite tables
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS suites (
suite_id BIGSERIAL PRIMARY KEY,
suite_key TEXT UNIQUE,
suite_name TEXT NOT NULL,
suite_group TEXT NOT NULL,
UNIQUE(suite_name, suite_group)
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS suite_results (
suite_result_id BIGSERIAL PRIMARY KEY,
suite_id BIGINT NOT NULL REFERENCES suites(suite_id) ON DELETE CASCADE,
success BOOLEAN NOT NULL,
errors TEXT NOT NULL,
duration BIGINT NOT NULL,
timestamp TIMESTAMP NOT NULL
)
`)
if err != nil {
return err
}
// Create endpoint tables
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoints (
endpoint_id BIGSERIAL PRIMARY KEY,
endpoint_key TEXT UNIQUE,
endpoint_name TEXT NOT NULL,
endpoint_group TEXT NOT NULL,
UNIQUE(endpoint_name, endpoint_group)
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_events (
endpoint_event_id BIGSERIAL PRIMARY KEY,
endpoint_id BIGINT NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
event_timestamp TIMESTAMP NOT NULL
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_results (
endpoint_result_id BIGSERIAL PRIMARY KEY,
endpoint_id BIGINT NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
success BOOLEAN NOT NULL,
errors TEXT NOT NULL,
connected BOOLEAN NOT NULL,
status BIGINT NOT NULL,
dns_rcode TEXT NOT NULL,
certificate_expiration BIGINT NOT NULL,
domain_expiration BIGINT NOT NULL,
hostname TEXT NOT NULL,
ip TEXT NOT NULL,
duration BIGINT NOT NULL,
timestamp TIMESTAMP NOT NULL,
suite_result_id BIGINT REFERENCES suite_results(suite_result_id) ON DELETE CASCADE
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_result_conditions (
endpoint_result_condition_id BIGSERIAL PRIMARY KEY,
endpoint_result_id BIGINT NOT NULL REFERENCES endpoint_results(endpoint_result_id) ON DELETE CASCADE,
condition TEXT NOT NULL,
success BOOLEAN NOT NULL
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_uptimes (
endpoint_uptime_id BIGSERIAL PRIMARY KEY,
endpoint_id BIGINT NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
hour_unix_timestamp BIGINT NOT NULL,
total_executions BIGINT NOT NULL,
successful_executions BIGINT NOT NULL,
total_response_time BIGINT NOT NULL,
UNIQUE(endpoint_id, hour_unix_timestamp)
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_alerts_triggered (
endpoint_alert_trigger_id BIGSERIAL PRIMARY KEY,
endpoint_id BIGINT NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
configuration_checksum TEXT NOT NULL,
resolve_key TEXT NOT NULL,
number_of_successes_in_a_row INTEGER NOT NULL,
UNIQUE(endpoint_id, configuration_checksum)
)
`)
if err != nil {
return err
}
// Create index for suite_results
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS suite_results_suite_id_idx ON suite_results (suite_id);
`)
// Silent table modifications TODO: Remove this in v6.0.0
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD IF NOT EXISTS domain_expiration BIGINT NOT NULL DEFAULT 0`)
// Add suite_result_id to endpoint_results table for suite endpoint linkage
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD COLUMN IF NOT EXISTS suite_result_id BIGINT REFERENCES suite_results(suite_result_id) ON DELETE CASCADE`)
// Create index for suite_result_id
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS endpoint_results_suite_result_id_idx ON endpoint_results(suite_result_id)`)
// Create index for endpoint_result_conditions
_, _ = s.db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_endpoint_result_conditions_endpoint_result_id ON endpoint_result_conditions (endpoint_result_id)`)
// Create index for endpoint_results
_, _ = s.db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_endpoint_results_endpoint_id ON endpoint_results (endpoint_id)`)
return err
}
================================================
FILE: storage/store/sql/specific_sqlite.go
================================================
package sql
func (s *Store) createSQLiteSchema() error {
// Create suite tables
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS suites (
suite_id INTEGER PRIMARY KEY,
suite_key TEXT UNIQUE,
suite_name TEXT NOT NULL,
suite_group TEXT NOT NULL,
UNIQUE(suite_name, suite_group)
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS suite_results (
suite_result_id INTEGER PRIMARY KEY,
suite_id INTEGER NOT NULL REFERENCES suites(suite_id) ON DELETE CASCADE,
success INTEGER NOT NULL,
errors TEXT NOT NULL,
duration INTEGER NOT NULL,
timestamp TIMESTAMP NOT NULL
)
`)
if err != nil {
return err
}
// Create endpoint tables
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoints (
endpoint_id INTEGER PRIMARY KEY,
endpoint_key TEXT UNIQUE,
endpoint_name TEXT NOT NULL,
endpoint_group TEXT NOT NULL,
UNIQUE(endpoint_name, endpoint_group)
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_events (
endpoint_event_id INTEGER PRIMARY KEY,
endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
event_timestamp TIMESTAMP NOT NULL
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_results (
endpoint_result_id INTEGER PRIMARY KEY,
endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
success INTEGER NOT NULL,
errors TEXT NOT NULL,
connected INTEGER NOT NULL,
status INTEGER NOT NULL,
dns_rcode TEXT NOT NULL,
certificate_expiration INTEGER NOT NULL,
domain_expiration INTEGER NOT NULL,
hostname TEXT NOT NULL,
ip TEXT NOT NULL,
duration INTEGER NOT NULL,
timestamp TIMESTAMP NOT NULL,
suite_result_id INTEGER REFERENCES suite_results(suite_result_id) ON DELETE CASCADE
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_result_conditions (
endpoint_result_condition_id INTEGER PRIMARY KEY,
endpoint_result_id INTEGER NOT NULL REFERENCES endpoint_results(endpoint_result_id) ON DELETE CASCADE,
condition TEXT NOT NULL,
success INTEGER NOT NULL
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_uptimes (
endpoint_uptime_id INTEGER PRIMARY KEY,
endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
hour_unix_timestamp INTEGER NOT NULL,
total_executions INTEGER NOT NULL,
successful_executions INTEGER NOT NULL,
total_response_time INTEGER NOT NULL,
UNIQUE(endpoint_id, hour_unix_timestamp)
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS endpoint_alerts_triggered (
endpoint_alert_trigger_id INTEGER PRIMARY KEY,
endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
configuration_checksum TEXT NOT NULL,
resolve_key TEXT NOT NULL,
number_of_successes_in_a_row INTEGER NOT NULL,
UNIQUE(endpoint_id, configuration_checksum)
)
`)
if err != nil {
return err
}
// Create indices for performance reasons
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS endpoint_results_endpoint_id_idx ON endpoint_results (endpoint_id);
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS endpoint_uptimes_endpoint_id_idx ON endpoint_uptimes (endpoint_id);
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS endpoint_result_conditions_endpoint_result_id_idx ON endpoint_result_conditions (endpoint_result_id);
`)
if err != nil {
return err
}
// Create index for suite_results
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS suite_results_suite_id_idx ON suite_results (suite_id);
`)
if err != nil {
return err
}
// Silent table modifications TODO: Remove this in v6.0.0
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD domain_expiration INTEGER NOT NULL DEFAULT 0`)
// Add suite_result_id to endpoint_results table for suite endpoint linkage
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD suite_result_id INTEGER REFERENCES suite_results(suite_result_id) ON DELETE CASCADE`)
// Create index for suite_result_id
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS endpoint_results_suite_result_id_idx ON endpoint_results(suite_result_id)`)
// Note: SQLite doesn't support DROP COLUMN in older versions, so we skip this cleanup
// The suite_id column in endpoints table will remain but unused
return err
}
================================================
FILE: storage/store/sql/sql.go
================================================
package sql
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/key"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/gocache/v2"
"github.com/TwiN/logr"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
)
//////////////////////////////////////////////////////////////////////////////////////////////////
// Note that only exported functions in this file may create, commit, or rollback a transaction //
//////////////////////////////////////////////////////////////////////////////////////////////////
const (
// arraySeparator is the separator used to separate multiple strings in a single column.
// It's a dirty hack, but it's only used for persisting errors, and since this data will likely only ever be used
// for aesthetic purposes, I deemed it wasn't worth the performance impact of yet another one-to-many table.
arraySeparator = "|~|"
eventsAboveMaximumCleanUpThreshold = 10 // Maximum number of events above the configured maximum before triggering a cleanup
resultsAboveMaximumCleanUpThreshold = 10 // Maximum number of results above the configured maximum before triggering a cleanup
uptimeTotalEntriesMergeThreshold = 100 // Maximum number of uptime entries before triggering a merge
uptimeAgeCleanUpThreshold = 32 * 24 * time.Hour // Maximum uptime age before triggering a cleanup
uptimeRetention = 30 * 24 * time.Hour // Minimum duration that must be kept to operate as intended
uptimeHourlyBuffer = 48 * time.Hour // Number of hours to buffer from now when determining which hourly uptime entries can be merged into daily uptime entries
cacheTTL = 10 * time.Minute
)
var (
// ErrPathNotSpecified is the error returned when the path parameter passed in NewStore is blank
ErrPathNotSpecified = errors.New("path cannot be empty")
// ErrDatabaseDriverNotSpecified is the error returned when the driver parameter passed in NewStore is blank
ErrDatabaseDriverNotSpecified = errors.New("database driver cannot be empty")
errNoRowsReturned = errors.New("expected a row to be returned, but none was")
)
// Store that leverages a database
type Store struct {
driver, path string
db *sql.DB
// writeThroughCache is a cache used to drastically decrease read latency by pre-emptively
// caching writes as they happen. If nil, writes are not cached.
writeThroughCache *gocache.Cache
maximumNumberOfResults int // maximum number of results that an endpoint can have
maximumNumberOfEvents int // maximum number of events that an endpoint can have
}
// NewStore initializes the database and creates the schema if it doesn't already exist in the path specified
func NewStore(driver, path string, caching bool, maximumNumberOfResults, maximumNumberOfEvents int) (*Store, error) {
if len(driver) == 0 {
return nil, ErrDatabaseDriverNotSpecified
}
if len(path) == 0 {
return nil, ErrPathNotSpecified
}
store := &Store{
driver: driver,
path: path,
maximumNumberOfResults: maximumNumberOfResults,
maximumNumberOfEvents: maximumNumberOfEvents,
}
var err error
if store.db, err = sql.Open(driver, path); err != nil {
return nil, err
}
if err := store.db.Ping(); err != nil {
return nil, err
}
if driver == "sqlite" {
_, _ = store.db.Exec("PRAGMA foreign_keys=ON")
_, _ = store.db.Exec("PRAGMA journal_mode=WAL")
_, _ = store.db.Exec("PRAGMA synchronous=NORMAL")
// Prevents driver from running into "database is locked" errors
// This is because we're using WAL to improve performance
store.db.SetMaxOpenConns(1)
}
if err = store.createSchema(); err != nil {
_ = store.db.Close()
return nil, err
}
if caching {
store.writeThroughCache = gocache.NewCache().WithMaxSize(10000)
}
return store, nil
}
// createSchema creates the schema required to perform all database operations.
func (s *Store) createSchema() error {
if s.driver == "sqlite" {
return s.createSQLiteSchema()
}
return s.createPostgresSchema()
}
// GetAllEndpointStatuses returns all monitored endpoint.Status
// with a subset of endpoint.Result defined by the page and pageSize parameters
func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
keys, err := s.getAllEndpointKeys(tx)
if err != nil {
_ = tx.Rollback()
return nil, err
}
endpointStatuses := make([]*endpoint.Status, 0, len(keys))
for _, key := range keys {
endpointStatus, err := s.getEndpointStatusByKey(tx, key, params)
if err != nil {
continue
}
endpointStatuses = append(endpointStatuses, endpointStatus)
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
}
return endpointStatuses, err
}
// GetEndpointStatus returns the endpoint status for a given endpoint name in the given group
func (s *Store) GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*endpoint.Status, error) {
return s.GetEndpointStatusByKey(key.ConvertGroupAndNameToKey(groupName, endpointName), params)
}
// GetEndpointStatusByKey returns the endpoint status for a given key
func (s *Store) GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
endpointStatus, err := s.getEndpointStatusByKey(tx, key, params)
if err != nil {
_ = tx.Rollback()
return nil, err
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
}
return endpointStatus, err
}
// GetUptimeByKey returns the uptime percentage during a time range
func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) {
if from.After(to) {
return 0, common.ErrInvalidTimeRange
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
endpointID, _, _, err := s.getEndpointIDGroupAndNameByKey(tx, key)
if err != nil {
_ = tx.Rollback()
return 0, err
}
uptime, _, err := s.getEndpointUptime(tx, endpointID, from, to)
if err != nil {
_ = tx.Rollback()
return 0, err
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
}
return uptime, nil
}
// GetAverageResponseTimeByKey returns the average response time in milliseconds (value) during a time range
func (s *Store) GetAverageResponseTimeByKey(key string, from, to time.Time) (int, error) {
if from.After(to) {
return 0, common.ErrInvalidTimeRange
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
endpointID, _, _, err := s.getEndpointIDGroupAndNameByKey(tx, key)
if err != nil {
_ = tx.Rollback()
return 0, err
}
averageResponseTime, err := s.getEndpointAverageResponseTime(tx, endpointID, from, to)
if err != nil {
_ = tx.Rollback()
return 0, err
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
}
return averageResponseTime, nil
}
// GetHourlyAverageResponseTimeByKey returns a map of hourly (key) average response time in milliseconds (value) during a time range
func (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error) {
if from.After(to) {
return nil, common.ErrInvalidTimeRange
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
endpointID, _, _, err := s.getEndpointIDGroupAndNameByKey(tx, key)
if err != nil {
_ = tx.Rollback()
return nil, err
}
hourlyAverageResponseTimes, err := s.getEndpointHourlyAverageResponseTimes(tx, endpointID, from, to)
if err != nil {
_ = tx.Rollback()
return nil, err
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
}
return hourlyAverageResponseTimes, nil
}
// InsertEndpointResult adds the observed result for the specified endpoint into the store
func (s *Store) InsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Result) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
endpointID, err := s.getEndpointID(tx, ep)
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
// Endpoint doesn't exist in the database, insert it
if endpointID, err = s.insertEndpoint(tx, ep); err != nil {
_ = tx.Rollback()
logr.Errorf("[sql.InsertEndpointResult] Failed to create endpoint with key=%s: %s", ep.Key(), err.Error())
return err
}
} else {
_ = tx.Rollback()
logr.Errorf("[sql.InsertEndpointResult] Failed to retrieve id of endpoint with key=%s: %s", ep.Key(), err.Error())
return err
}
}
// First, we need to check if we need to insert a new event.
//
// A new event must be added if either of the following cases happen:
// 1. There is only 1 event. The total number of events for an endpoint can only be 1 if the only existing event is
// of type EventStart, in which case we will have to create a new event of type EventHealthy or EventUnhealthy
// based on result.Success.
// 2. The lastResult.Success != result.Success. This implies that the endpoint went from healthy to unhealthy or
// vice versa, in which case we will have to create a new event of type EventHealthy or EventUnhealthy
// based on result.Success.
numberOfEvents, err := s.getNumberOfEventsByEndpointID(tx, endpointID)
if err != nil {
// Silently fail
logr.Errorf("[sql.InsertEndpointResult] Failed to retrieve total number of events for endpoint with key=%s: %s", ep.Key(), err.Error())
}
if numberOfEvents == 0 {
// There's no events yet, which means we need to add the EventStart and the first healthy/unhealthy event
err = s.insertEndpointEvent(tx, endpointID, &endpoint.Event{
Type: endpoint.EventStart,
Timestamp: result.Timestamp.Add(-50 * time.Millisecond),
})
if err != nil {
// Silently fail
logr.Errorf("[sql.InsertEndpointResult] Failed to insert event=%s for endpoint with key=%s: %s", endpoint.EventStart, ep.Key(), err.Error())
}
event := endpoint.NewEventFromResult(result)
if err = s.insertEndpointEvent(tx, endpointID, event); err != nil {
// Silently fail
logr.Errorf("[sql.InsertEndpointResult] Failed to insert event=%s for endpoint with key=%s: %s", event.Type, ep.Key(), err.Error())
}
} else {
// 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.
// If the final outcome (success or failure) of the previous and the new result aren't the same, it means
// that the endpoint either went from Healthy to Unhealthy or Unhealthy -> Healthy, therefore, we'll add
// an event to mark the change in state
if lastResultSuccess != result.Success {
event := endpoint.NewEventFromResult(result)
if err = s.insertEndpointEvent(tx, endpointID, event); err != nil {
// Silently fail
logr.Errorf("[sql.InsertEndpointResult] Failed to insert event=%s for endpoint with key=%s: %s", event.Type, ep.Key(), err.Error())
}
}
}
// Clean up old events if we're above the threshold
// This lets us both keep the table clean without impacting performance too much
// (since we're only deleting MaximumNumberOfEvents at a time instead of 1)
if numberOfEvents > int64(s.maximumNumberOfEvents+eventsAboveMaximumCleanUpThreshold) {
if err = s.deleteOldEndpointEvents(tx, endpointID); err != nil {
logr.Errorf("[sql.InsertEndpointResult] Failed to delete old events for endpoint with key=%s: %s", ep.Key(), err.Error())
}
}
}
// Second, we need to insert the result.
if err = s.insertEndpointResult(tx, endpointID, result); err != nil {
logr.Errorf("[sql.InsertEndpointResult] Failed to insert result for endpoint with key=%s: %s", ep.Key(), err.Error())
_ = tx.Rollback() // If we can't insert the result, we'll rollback now since there's no point continuing
return err
}
// Clean up old results
numberOfResults, err := s.getNumberOfResultsByEndpointID(tx, endpointID)
if err != nil {
logr.Errorf("[sql.InsertEndpointResult] Failed to retrieve total number of results for endpoint with key=%s: %s", ep.Key(), err.Error())
} else {
if numberOfResults > int64(s.maximumNumberOfResults+resultsAboveMaximumCleanUpThreshold) {
if err = s.deleteOldEndpointResults(tx, endpointID); err != nil {
logr.Errorf("[sql.InsertEndpointResult] Failed to delete old results for endpoint with key=%s: %s", ep.Key(), err.Error())
}
}
}
// Finally, we need to insert the uptime data.
// Because the uptime data significantly outlives the results, we can't rely on the results for determining the uptime
if err = s.updateEndpointUptime(tx, endpointID, result); err != nil {
logr.Errorf("[sql.InsertEndpointResult] Failed to update uptime for endpoint with key=%s: %s", ep.Key(), err.Error())
}
// Merge hourly uptime entries that can be merged into daily entries and clean up old uptime entries
numberOfUptimeEntries, err := s.getNumberOfUptimeEntriesByEndpointID(tx, endpointID)
if err != nil {
logr.Errorf("[sql.InsertEndpointResult] Failed to retrieve total number of uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error())
} else {
// Merge older hourly uptime entries into daily uptime entries if we have more than uptimeTotalEntriesMergeThreshold
if numberOfUptimeEntries >= uptimeTotalEntriesMergeThreshold {
logr.Infof("[sql.InsertEndpointResult] Merging hourly uptime entries for endpoint with key=%s; This is a lot of work, it shouldn't happen too often", ep.Key())
if err = s.mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries(tx, endpointID); err != nil {
logr.Errorf("[sql.InsertEndpointResult] Failed to merge hourly uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error())
}
}
}
// Clean up outdated uptime entries
// In most cases, this would be handled by mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries,
// but if Gatus was temporarily shut down, we might have some old entries that need to be cleaned up
ageOfOldestUptimeEntry, err := s.getAgeOfOldestEndpointUptimeEntry(tx, endpointID)
if err != nil {
logr.Errorf("[sql.InsertEndpointResult] Failed to retrieve oldest endpoint uptime entry for endpoint with key=%s: %s", ep.Key(), err.Error())
} else {
if ageOfOldestUptimeEntry > uptimeAgeCleanUpThreshold {
if err = s.deleteOldUptimeEntries(tx, endpointID, time.Now().Add(-(uptimeRetention + time.Hour))); err != nil {
logr.Errorf("[sql.InsertEndpointResult] Failed to delete old uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error())
}
}
}
if s.writeThroughCache != nil {
cacheKeysToRefresh := s.writeThroughCache.GetKeysByPattern(ep.Key()+"*", 0)
for _, cacheKey := range cacheKeysToRefresh {
s.writeThroughCache.Delete(cacheKey)
endpointKey, params, err := extractKeyAndParamsFromCacheKey(cacheKey)
if err != nil {
logr.Errorf("[sql.InsertEndpointResult] Silently deleting cache key %s instead of refreshing due to error: %s", cacheKey, err.Error())
continue
}
// Retrieve the endpoint status by key, which will in turn refresh the cache
_, _ = s.getEndpointStatusByKey(tx, endpointKey, params)
}
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
}
return err
}
// DeleteAllEndpointStatusesNotInKeys removes all rows owned by an endpoint whose key is not within the keys provided
func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int {
logr.Debugf("[sql.DeleteAllEndpointStatusesNotInKeys] Called with %d keys", len(keys))
var err error
var result sql.Result
if len(keys) == 0 {
// Delete everything
logr.Debugf("[sql.DeleteAllEndpointStatusesNotInKeys] No keys provided, deleting all endpoints")
result, err = s.db.Exec("DELETE FROM endpoints")
} else {
// First check what we're about to delete
args := make([]interface{}, 0, len(keys))
checkQuery := "SELECT endpoint_key FROM endpoints WHERE endpoint_key NOT IN ("
for i := range keys {
checkQuery += fmt.Sprintf("$%d,", i+1)
args = append(args, keys[i])
}
checkQuery = checkQuery[:len(checkQuery)-1] + ")"
rows, checkErr := s.db.Query(checkQuery, args...)
if checkErr == nil {
defer rows.Close()
var deletedKeys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err == nil {
deletedKeys = append(deletedKeys, key)
}
}
if len(deletedKeys) > 0 {
logr.Infof("[sql.DeleteAllEndpointStatusesNotInKeys] Deleting endpoints with keys: %v", deletedKeys)
} else {
logr.Debugf("[sql.DeleteAllEndpointStatusesNotInKeys] No endpoints to delete")
}
}
query := "DELETE FROM endpoints WHERE endpoint_key NOT IN ("
for i := range keys {
query += fmt.Sprintf("$%d,", i+1)
}
query = query[:len(query)-1] + ")" // Remove the last comma and add the closing parenthesis
result, err = s.db.Exec(query, args...)
}
if err != nil {
logr.Errorf("[sql.DeleteAllEndpointStatusesNotInKeys] Failed to delete rows that do not belong to any of keys=%v: %s", keys, err.Error())
return 0
}
if s.writeThroughCache != nil {
// It's easier to just wipe out the entire cache than to try to find all keys that are not in the keys list
// This only happens on start and during tests, so it's fine for us to just clear the cache without worrying
// about performance
_ = s.writeThroughCache.DeleteKeysByPattern("*")
}
// Return number of rows deleted
rowsAffects, _ := result.RowsAffected()
return int(rowsAffects)
}
// GetTriggeredEndpointAlert returns whether the triggered alert for the specified endpoint as well as the necessary information to resolve it
func (s *Store) GetTriggeredEndpointAlert(ep *endpoint.Endpoint, alert *alert.Alert) (exists bool, resolveKey string, numberOfSuccessesInARow int, err error) {
//logr.Debugf("[sql.GetTriggeredEndpointAlert] Getting triggered alert with checksum=%s for endpoint with key=%s", alert.Checksum(), ep.Key())
err = s.db.QueryRow(
"SELECT resolve_key, number_of_successes_in_a_row FROM endpoint_alerts_triggered WHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1) AND configuration_checksum = $2",
ep.Key(),
alert.Checksum(),
).Scan(&resolveKey, &numberOfSuccessesInARow)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, "", 0, nil
}
return false, "", 0, err
}
return true, resolveKey, numberOfSuccessesInARow, nil
}
// UpsertTriggeredEndpointAlert inserts/updates a triggered alert for an endpoint
// Used for persistence of triggered alerts across application restarts
func (s *Store) UpsertTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error {
//logr.Debugf("[sql.UpsertTriggeredEndpointAlert] Upserting triggered alert with checksum=%s for endpoint with key=%s", triggeredAlert.Checksum(), ep.Key())
tx, err := s.db.Begin()
if err != nil {
return err
}
endpointID, err := s.getEndpointID(tx, ep)
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
// Endpoint doesn't exist in the database, insert it
// This shouldn't happen, but we'll handle it anyway
if endpointID, err = s.insertEndpoint(tx, ep); err != nil {
_ = tx.Rollback()
logr.Errorf("[sql.UpsertTriggeredEndpointAlert] Failed to create endpoint with key=%s: %s", ep.Key(), err.Error())
return err
}
} else {
_ = tx.Rollback()
logr.Errorf("[sql.UpsertTriggeredEndpointAlert] Failed to retrieve id of endpoint with key=%s: %s", ep.Key(), err.Error())
return err
}
}
_, err = tx.Exec(
`
INSERT INTO endpoint_alerts_triggered (endpoint_id, configuration_checksum, resolve_key, number_of_successes_in_a_row)
VALUES ($1, $2, $3, $4)
ON CONFLICT(endpoint_id, configuration_checksum) DO UPDATE SET
resolve_key = $3,
number_of_successes_in_a_row = $4
`,
endpointID,
triggeredAlert.Checksum(),
triggeredAlert.ResolveKey,
ep.NumberOfSuccessesInARow, // We only persist NumberOfSuccessesInARow, because all alerts in this table are already triggered
)
if err != nil {
_ = tx.Rollback()
logr.Errorf("[sql.UpsertTriggeredEndpointAlert] Failed to persist triggered alert for endpoint with key=%s: %s", ep.Key(), err.Error())
return err
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
}
return nil
}
// DeleteTriggeredEndpointAlert deletes a triggered alert for an endpoint
func (s *Store) DeleteTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error {
//logr.Debugf("[sql.DeleteTriggeredEndpointAlert] Deleting triggered alert with checksum=%s for endpoint with key=%s", triggeredAlert.Checksum(), ep.Key())
_, err := s.db.Exec("DELETE FROM endpoint_alerts_triggered WHERE configuration_checksum = $1 AND endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $2 LIMIT 1)", triggeredAlert.Checksum(), ep.Key())
return err
}
// DeleteAllTriggeredAlertsNotInChecksumsByEndpoint removes all triggered alerts owned by an endpoint whose alert
// configurations are not provided in the checksums list.
// This prevents triggered alerts that have been removed or modified from lingering in the database.
func (s *Store) DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int {
//logr.Debugf("[sql.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint] Deleting triggered alerts for endpoint with key=%s that do not belong to any of checksums=%v", ep.Key(), checksums)
var err error
var result sql.Result
if len(checksums) == 0 {
// No checksums? Then it means there are no (enabled) alerts configured for that endpoint, so we can get rid of all
// persisted triggered alerts for that endpoint
result, err = s.db.Exec("DELETE FROM endpoint_alerts_triggered WHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1)", ep.Key())
} else {
args := make([]interface{}, 0, len(checksums)+1)
args = append(args, ep.Key())
query := `DELETE FROM endpoint_alerts_triggered
WHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1)
AND configuration_checksum NOT IN (`
for i := range checksums {
query += fmt.Sprintf("$%d,", i+2)
args = append(args, checksums[i])
}
query = query[:len(query)-1] + ")" // Remove the last comma and add the closing parenthesis
result, err = s.db.Exec(query, args...)
}
if err != nil {
logr.Errorf("[sql.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint] Failed to delete rows for endpoint with key=%s that do not belong to any of checksums=%v: %s", ep.Key(), checksums, err.Error())
return 0
}
// Return number of rows deleted
rowsAffects, _ := result.RowsAffected()
return int(rowsAffects)
}
// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp
func (s *Store) HasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error) {
if timestamp.IsZero() {
return false, errors.New("timestamp is zero")
}
var count int
err := s.db.QueryRow(
"SELECT COUNT(*) FROM endpoint_results WHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1) AND timestamp > $2",
key,
timestamp.UTC(),
).Scan(&count)
if err != nil {
// If the endpoint doesn't exist, we return false instead of an error
return false, nil
}
return count > 0, nil
}
// Clear deletes everything from the store
func (s *Store) Clear() {
_, _ = s.db.Exec("DELETE FROM endpoints")
if s.writeThroughCache != nil {
_ = s.writeThroughCache.DeleteKeysByPattern("*")
}
}
// Save does nothing, because this store is immediately persistent.
func (s *Store) Save() error {
return nil
}
// Close the database handle
func (s *Store) Close() {
_ = s.db.Close()
if s.writeThroughCache != nil {
// Clear the cache too. If the store's been closed, we don't want to keep the cache around.
_ = s.writeThroughCache.DeleteKeysByPattern("*")
}
}
// insertEndpoint inserts an endpoint in the store and returns the generated id of said endpoint
func (s *Store) insertEndpoint(tx *sql.Tx, ep *endpoint.Endpoint) (int64, error) {
//logr.Debugf("[sql.insertEndpoint] Inserting endpoint with group=%s and name=%s", ep.Group, ep.Name)
var id int64
err := tx.QueryRow(
"INSERT INTO endpoints (endpoint_key, endpoint_name, endpoint_group) VALUES ($1, $2, $3) RETURNING endpoint_id",
ep.Key(),
ep.Name,
ep.Group,
).Scan(&id)
if err != nil {
return 0, err
}
return id, nil
}
// insertEndpointEvent inserts en event in the store
func (s *Store) insertEndpointEvent(tx *sql.Tx, endpointID int64, event *endpoint.Event) error {
_, err := tx.Exec(
"INSERT INTO endpoint_events (endpoint_id, event_type, event_timestamp) VALUES ($1, $2, $3)",
endpointID,
event.Type,
event.Timestamp.UTC(),
)
if err != nil {
return err
}
return nil
}
// insertEndpointResult inserts a result in the store
func (s *Store) insertEndpointResult(tx *sql.Tx, endpointID int64, result *endpoint.Result) error {
return s.insertEndpointResultWithSuiteID(tx, endpointID, result, nil)
}
// insertEndpointResultWithSuiteID inserts a result in the store with optional suite linkage
func (s *Store) insertEndpointResultWithSuiteID(tx *sql.Tx, endpointID int64, result *endpoint.Result, suiteResultID *int64) error {
var endpointResultID int64
err := tx.QueryRow(
`
INSERT INTO endpoint_results (endpoint_id, success, errors, connected, status, dns_rcode, certificate_expiration, domain_expiration, hostname, ip, duration, timestamp, suite_result_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING endpoint_result_id
`,
endpointID,
result.Success,
strings.Join(result.Errors, arraySeparator),
result.Connected,
result.HTTPStatus,
result.DNSRCode,
result.CertificateExpiration,
result.DomainExpiration,
result.Hostname,
result.IP,
result.Duration,
result.Timestamp.UTC(),
suiteResultID,
).Scan(&endpointResultID)
if err != nil {
return err
}
return s.insertConditionResults(tx, endpointResultID, result.ConditionResults)
}
func (s *Store) insertConditionResults(tx *sql.Tx, endpointResultID int64, conditionResults []*endpoint.ConditionResult) error {
var err error
for _, cr := range conditionResults {
_, err = tx.Exec("INSERT INTO endpoint_result_conditions (endpoint_result_id, condition, success) VALUES ($1, $2, $3)",
endpointResultID,
cr.Condition,
cr.Success,
)
if err != nil {
return err
}
}
return nil
}
func (s *Store) updateEndpointUptime(tx *sql.Tx, endpointID int64, result *endpoint.Result) error {
unixTimestampFlooredAtHour := result.Timestamp.Truncate(time.Hour).Unix()
var successfulExecutions int
if result.Success {
successfulExecutions = 1
}
_, err := tx.Exec(
`
INSERT INTO endpoint_uptimes (endpoint_id, hour_unix_timestamp, total_executions, successful_executions, total_response_time)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT(endpoint_id, hour_unix_timestamp) DO UPDATE SET
total_executions = excluded.total_executions + endpoint_uptimes.total_executions,
successful_executions = excluded.successful_executions + endpoint_uptimes.successful_executions,
total_response_time = excluded.total_response_time + endpoint_uptimes.total_response_time
`,
endpointID,
unixTimestampFlooredAtHour,
1,
successfulExecutions,
result.Duration.Milliseconds(),
)
return err
}
func (s *Store) getAllEndpointKeys(tx *sql.Tx) (keys []string, err error) {
// Only get endpoints that have at least one result not linked to a suite
// This excludes endpoints that only exist as part of suites
// Using JOIN for better performance than EXISTS subquery
rows, err := tx.Query(`
SELECT DISTINCT e.endpoint_key
FROM endpoints e
INNER JOIN endpoint_results er ON e.endpoint_id = er.endpoint_id
WHERE er.suite_result_id IS NULL
ORDER BY e.endpoint_key
`)
if err != nil {
return nil, err
}
for rows.Next() {
var key string
_ = rows.Scan(&key)
keys = append(keys, key)
}
return
}
func (s *Store) getEndpointStatusByKey(tx *sql.Tx, key string, parameters *paging.EndpointStatusParams) (*endpoint.Status, error) {
var cacheKey string
if s.writeThroughCache != nil {
cacheKey = generateCacheKey(key, parameters)
if cachedEndpointStatus, exists := s.writeThroughCache.Get(cacheKey); exists {
if castedCachedEndpointStatus, ok := cachedEndpointStatus.(*endpoint.Status); ok {
return castedCachedEndpointStatus, nil
}
}
}
endpointID, group, endpointName, err := s.getEndpointIDGroupAndNameByKey(tx, key)
if err != nil {
return nil, err
}
endpointStatus := endpoint.NewStatus(group, endpointName)
if parameters.EventsPageSize > 0 {
if endpointStatus.Events, err = s.getEndpointEventsByEndpointID(tx, endpointID, parameters.EventsPage, parameters.EventsPageSize); err != nil {
logr.Errorf("[sql.getEndpointStatusByKey] Failed to retrieve events for key=%s: %s", key, err.Error())
}
}
if parameters.ResultsPageSize > 0 {
if endpointStatus.Results, err = s.getEndpointResultsByEndpointID(tx, endpointID, parameters.ResultsPage, parameters.ResultsPageSize); err != nil {
logr.Errorf("[sql.getEndpointStatusByKey] Failed to retrieve results for key=%s: %s", key, err.Error())
}
}
if s.writeThroughCache != nil {
s.writeThroughCache.SetWithTTL(cacheKey, endpointStatus, cacheTTL)
}
return endpointStatus, nil
}
func (s *Store) getEndpointIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64, group, name string, err error) {
err = tx.QueryRow(
`
SELECT endpoint_id, endpoint_group, endpoint_name
FROM endpoints
WHERE endpoint_key = $1
LIMIT 1
`,
key,
).Scan(&id, &group, &name)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, "", "", common.ErrEndpointNotFound
}
return 0, "", "", err
}
return
}
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 (
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
`,
endpointID,
pageSize,
(page-1)*pageSize,
)
if err != nil {
return nil, err
}
for rows.Next() {
event := &endpoint.Event{}
_ = rows.Scan(&event.Type, &event.Timestamp)
events = append(events, event)
}
return
}
func (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (results []*endpoint.Result, err error) {
rows, err := tx.Query(
`
SELECT endpoint_result_id, success, errors, connected, status, dns_rcode, certificate_expiration, domain_expiration, hostname, ip, duration, timestamp
FROM endpoint_results
WHERE endpoint_id = $1
ORDER BY endpoint_result_id DESC -- Normally, we'd sort by timestamp, but sorting by endpoint_result_id is faster
LIMIT $2 OFFSET $3
`,
endpointID,
pageSize,
(page-1)*pageSize,
)
if err != nil {
return nil, err
}
idResultMap := make(map[int64]*endpoint.Result)
for rows.Next() {
result := &endpoint.Result{}
var id int64
var joinedErrors string
err = rows.Scan(&id, &result.Success, &joinedErrors, &result.Connected, &result.HTTPStatus, &result.DNSRCode, &result.CertificateExpiration, &result.DomainExpiration, &result.Hostname, &result.IP, &result.Duration, &result.Timestamp)
if err != nil {
logr.Errorf("[sql.getEndpointResultsByEndpointID] Silently failed to retrieve endpoint result for endpointID=%d: %s", endpointID, err.Error())
err = nil
}
if len(joinedErrors) != 0 {
result.Errors = strings.Split(joinedErrors, arraySeparator)
}
// This is faster than using a subselect
results = append([]*endpoint.Result{result}, results...)
idResultMap[id] = result
}
if len(idResultMap) == 0 {
// If there's no result, we'll just return an empty/nil slice
return
}
// Get condition results
args := make([]interface{}, 0, len(idResultMap))
query := `SELECT endpoint_result_id, condition, success
FROM endpoint_result_conditions
WHERE endpoint_result_id IN (`
index := 1
for endpointResultID := range idResultMap {
query += "$" + strconv.Itoa(index) + ","
args = append(args, endpointResultID)
index++
}
query = query[:len(query)-1] + ")"
rows, err = tx.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close() // explicitly defer the close in case an error happens during the scan
for rows.Next() {
conditionResult := &endpoint.ConditionResult{}
var endpointResultID int64
if err = rows.Scan(&endpointResultID, &conditionResult.Condition, &conditionResult.Success); err != nil {
return
}
idResultMap[endpointResultID].ConditionResults = append(idResultMap[endpointResultID].ConditionResults, conditionResult)
}
return
}
func (s *Store) getEndpointUptime(tx *sql.Tx, endpointID int64, from, to time.Time) (uptime float64, avgResponseTime time.Duration, err error) {
rows, err := tx.Query(
`
SELECT SUM(total_executions), SUM(successful_executions), SUM(total_response_time)
FROM endpoint_uptimes
WHERE endpoint_id = $1
AND hour_unix_timestamp >= $2
AND hour_unix_timestamp <= $3
`,
endpointID,
from.Unix(),
to.Unix(),
)
if err != nil {
return 0, 0, err
}
var totalExecutions, totalSuccessfulExecutions, totalResponseTime int
for rows.Next() {
_ = rows.Scan(&totalExecutions, &totalSuccessfulExecutions, &totalResponseTime)
}
if totalExecutions > 0 {
uptime = float64(totalSuccessfulExecutions) / float64(totalExecutions)
avgResponseTime = time.Duration(float64(totalResponseTime)/float64(totalExecutions)) * time.Millisecond
}
return
}
func (s *Store) getEndpointAverageResponseTime(tx *sql.Tx, endpointID int64, from, to time.Time) (int, error) {
rows, err := tx.Query(
`
SELECT SUM(total_executions), SUM(total_response_time)
FROM endpoint_uptimes
WHERE endpoint_id = $1
AND total_executions > 0
AND hour_unix_timestamp >= $2
AND hour_unix_timestamp <= $3
`,
endpointID,
from.Unix(),
to.Unix(),
)
if err != nil {
return 0, err
}
var totalExecutions, totalResponseTime int
for rows.Next() {
_ = rows.Scan(&totalExecutions, &totalResponseTime)
}
if totalExecutions == 0 {
return 0, nil
}
return int(float64(totalResponseTime) / float64(totalExecutions)), nil
}
func (s *Store) getEndpointHourlyAverageResponseTimes(tx *sql.Tx, endpointID int64, from, to time.Time) (map[int64]int, error) {
rows, err := tx.Query(
`
SELECT hour_unix_timestamp, total_executions, total_response_time
FROM endpoint_uptimes
WHERE endpoint_id = $1
AND total_executions > 0
AND hour_unix_timestamp >= $2
AND hour_unix_timestamp <= $3
`,
endpointID,
from.Unix(),
to.Unix(),
)
if err != nil {
return nil, err
}
var totalExecutions, totalResponseTime int
var unixTimestampFlooredAtHour int64
hourlyAverageResponseTimes := make(map[int64]int)
for rows.Next() {
_ = rows.Scan(&unixTimestampFlooredAtHour, &totalExecutions, &totalResponseTime)
hourlyAverageResponseTimes[unixTimestampFlooredAtHour] = int(float64(totalResponseTime) / float64(totalExecutions))
}
return hourlyAverageResponseTimes, nil
}
func (s *Store) getEndpointID(tx *sql.Tx, ep *endpoint.Endpoint) (int64, error) {
var id int64
err := tx.QueryRow("SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1", ep.Key()).Scan(&id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, common.ErrEndpointNotFound
}
return 0, err
}
return id, nil
}
func (s *Store) getNumberOfEventsByEndpointID(tx *sql.Tx, endpointID int64) (int64, error) {
var numberOfEvents int64
err := tx.QueryRow("SELECT COUNT(1) FROM endpoint_events WHERE endpoint_id = $1", endpointID).Scan(&numberOfEvents)
return numberOfEvents, err
}
func (s *Store) getNumberOfResultsByEndpointID(tx *sql.Tx, endpointID int64) (int64, error) {
var numberOfResults int64
err := tx.QueryRow("SELECT COUNT(1) FROM endpoint_results WHERE endpoint_id = $1", endpointID).Scan(&numberOfResults)
return numberOfResults, err
}
func (s *Store) getNumberOfUptimeEntriesByEndpointID(tx *sql.Tx, endpointID int64) (int64, error) {
var numberOfUptimeEntries int64
err := tx.QueryRow("SELECT COUNT(1) FROM endpoint_uptimes WHERE endpoint_id = $1", endpointID).Scan(&numberOfUptimeEntries)
return numberOfUptimeEntries, err
}
func (s *Store) getAgeOfOldestEndpointUptimeEntry(tx *sql.Tx, endpointID int64) (time.Duration, error) {
rows, err := tx.Query(
`
SELECT hour_unix_timestamp
FROM endpoint_uptimes
WHERE endpoint_id = $1
ORDER BY hour_unix_timestamp
LIMIT 1
`,
endpointID,
)
if err != nil {
return 0, err
}
var oldestEndpointUptimeUnixTimestamp int64
var found bool
for rows.Next() {
_ = rows.Scan(&oldestEndpointUptimeUnixTimestamp)
found = true
}
if !found {
return 0, errNoRowsReturned
}
return time.Since(time.Unix(oldestEndpointUptimeUnixTimestamp, 0)), nil
}
func (s *Store) getLastEndpointResultSuccessValue(tx *sql.Tx, endpointID int64) (bool, error) {
var success bool
err := tx.QueryRow("SELECT success FROM endpoint_results WHERE endpoint_id = $1 ORDER BY endpoint_result_id DESC LIMIT 1", endpointID).Scan(&success)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, errNoRowsReturned
}
return false, err
}
return success, nil
}
// deleteOldEndpointEvents deletes endpoint events that are no longer needed
func (s *Store) deleteOldEndpointEvents(tx *sql.Tx, endpointID int64) error {
_, err := tx.Exec(
`
DELETE FROM endpoint_events
WHERE endpoint_id = $1
AND endpoint_event_id NOT IN (
SELECT endpoint_event_id
FROM endpoint_events
WHERE endpoint_id = $1
ORDER BY endpoint_event_id DESC
LIMIT $2
)
`,
endpointID,
s.maximumNumberOfEvents,
)
return err
}
// deleteOldEndpointResults deletes endpoint results that are no longer needed
func (s *Store) deleteOldEndpointResults(tx *sql.Tx, endpointID int64) error {
_, err := tx.Exec(
`
DELETE FROM endpoint_results
WHERE endpoint_id = $1
AND endpoint_result_id NOT IN (
SELECT endpoint_result_id
FROM endpoint_results
WHERE endpoint_id = $1
ORDER BY endpoint_result_id DESC
LIMIT $2
)
`,
endpointID,
s.maximumNumberOfResults,
)
return err
}
func (s *Store) deleteOldUptimeEntries(tx *sql.Tx, endpointID int64, maxAge time.Time) error {
_, err := tx.Exec("DELETE FROM endpoint_uptimes WHERE endpoint_id = $1 AND hour_unix_timestamp < $2", endpointID, maxAge.Unix())
return err
}
// mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries merges all hourly uptime entries older than
// uptimeHourlyMergeThreshold from now into daily uptime entries by summing all hourly entries of the same day into a
// single entry.
//
// This effectively limits the number of uptime entries to (48+(n-2)) where 48 is for the first 48 entries with hourly
// entries (defined by uptimeHourlyBuffer) and n is the number of days for all entries older than 48 hours.
// Supporting 30d of entries would then result in far less than 24*30=720 entries.
func (s *Store) mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries(tx *sql.Tx, endpointID int64) error {
// Calculate timestamp of the first full day of uptime entries that would not impact the uptime calculation for 24h badges
// The logic is that once at least 48 hours passed, we:
// - No longer need to worry about keeping hourly entries
// - Don't have to worry about new hourly entries being inserted, as the day has already passed
// which implies that no matter at what hour of the day we are, any timestamp + 48h floored to the current day
// will never impact the 24h uptime badge calculation
now := time.Now()
minThreshold := now.Add(-uptimeHourlyBuffer)
minThreshold = time.Date(minThreshold.Year(), minThreshold.Month(), minThreshold.Day(), 0, 0, 0, 0, minThreshold.Location())
maxThreshold := now.Add(-uptimeRetention)
// Get all uptime entries older than uptimeHourlyMergeThreshold
rows, err := tx.Query(
`
SELECT hour_unix_timestamp, total_executions, successful_executions, total_response_time
FROM endpoint_uptimes
WHERE endpoint_id = $1
AND hour_unix_timestamp < $2
AND hour_unix_timestamp >= $3
`,
endpointID,
minThreshold.Unix(),
maxThreshold.Unix(),
)
if err != nil {
return err
}
type Entry struct {
totalExecutions int
successfulExecutions int
totalResponseTime int
}
dailyEntries := make(map[int64]*Entry)
for rows.Next() {
var unixTimestamp int64
entry := Entry{}
if err = rows.Scan(&unixTimestamp, &entry.totalExecutions, &entry.successfulExecutions, &entry.totalResponseTime); err != nil {
return err
}
timestamp := time.Unix(unixTimestamp, 0)
unixTimestampFlooredAtDay := time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(), 0, 0, 0, 0, timestamp.Location()).Unix()
if dailyEntry := dailyEntries[unixTimestampFlooredAtDay]; dailyEntry == nil {
dailyEntries[unixTimestampFlooredAtDay] = &entry
} else {
dailyEntries[unixTimestampFlooredAtDay].totalExecutions += entry.totalExecutions
dailyEntries[unixTimestampFlooredAtDay].successfulExecutions += entry.successfulExecutions
dailyEntries[unixTimestampFlooredAtDay].totalResponseTime += entry.totalResponseTime
}
}
// Delete older hourly uptime entries
_, err = tx.Exec("DELETE FROM endpoint_uptimes WHERE endpoint_id = $1 AND hour_unix_timestamp < $2", endpointID, minThreshold.Unix())
if err != nil {
return err
}
// Insert new daily uptime entries
for unixTimestamp, entry := range dailyEntries {
_, err = tx.Exec(
`
INSERT INTO endpoint_uptimes (endpoint_id, hour_unix_timestamp, total_executions, successful_executions, total_response_time)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT(endpoint_id, hour_unix_timestamp) DO UPDATE SET
total_executions = $3,
successful_executions = $4,
total_response_time = $5
`,
endpointID,
unixTimestamp,
entry.totalExecutions,
entry.successfulExecutions,
entry.totalResponseTime,
)
if err != nil {
return err
}
}
// TODO: Find a way to ignore entries that were already merged?
return nil
}
func generateCacheKey(endpointKey string, p *paging.EndpointStatusParams) string {
return fmt.Sprintf("%s-%d-%d-%d-%d", endpointKey, p.EventsPage, p.EventsPageSize, p.ResultsPage, p.ResultsPageSize)
}
func extractKeyAndParamsFromCacheKey(cacheKey string) (string, *paging.EndpointStatusParams, error) {
parts := strings.Split(cacheKey, "-")
if len(parts) < 5 {
return "", nil, fmt.Errorf("invalid cache key: %s", cacheKey)
}
params := &paging.EndpointStatusParams{}
var err error
if params.EventsPage, err = strconv.Atoi(parts[len(parts)-4]); err != nil {
return "", nil, fmt.Errorf("invalid cache key: %w", err)
}
if params.EventsPageSize, err = strconv.Atoi(parts[len(parts)-3]); err != nil {
return "", nil, fmt.Errorf("invalid cache key: %w", err)
}
if params.ResultsPage, err = strconv.Atoi(parts[len(parts)-2]); err != nil {
return "", nil, fmt.Errorf("invalid cache key: %w", err)
}
if params.ResultsPageSize, err = strconv.Atoi(parts[len(parts)-1]); err != nil {
return "", nil, fmt.Errorf("invalid cache key: %w", err)
}
return strings.Join(parts[:len(parts)-4], "-"), params, nil
}
// GetAllSuiteStatuses returns all monitored suite statuses
func (s *Store) GetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.Status, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// Get all suites
rows, err := tx.Query(`
SELECT suite_id, suite_key, suite_name, suite_group
FROM suites
ORDER BY suite_key
`)
if err != nil {
return nil, err
}
defer rows.Close()
var suiteStatuses []*suite.Status
for rows.Next() {
var suiteID int64
var key, name, group string
if err = rows.Scan(&suiteID, &key, &name, &group); err != nil {
return nil, err
}
status := &suite.Status{
Name: name,
Group: group,
Key: key,
Results: []*suite.Result{},
}
// Get suite results with pagination
pageSize := 20
page := 1
if params != nil {
if params.PageSize > 0 {
pageSize = params.PageSize
}
if params.Page > 0 {
page = params.Page
}
}
status.Results, err = s.getSuiteResults(tx, suiteID, page, pageSize)
if err != nil {
logr.Errorf("[sql.GetAllSuiteStatuses] Failed to retrieve results for suite_id=%d: %s", suiteID, err.Error())
}
// Populate Name and Group fields on each result
for _, result := range status.Results {
result.Name = name
result.Group = group
}
suiteStatuses = append(suiteStatuses, status)
}
if err = tx.Commit(); err != nil {
return nil, err
}
return suiteStatuses, nil
}
// GetSuiteStatusByKey returns the suite status for a given key
func (s *Store) GetSuiteStatusByKey(key string, params *paging.SuiteStatusParams) (*suite.Status, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var suiteID int64
var name, group string
err = tx.QueryRow(`
SELECT suite_id, suite_name, suite_group
FROM suites
WHERE suite_key = $1
`, key).Scan(&suiteID, &name, &group)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
status := &suite.Status{
Name: name,
Group: group,
Key: key,
Results: []*suite.Result{},
}
// Get suite results with pagination
pageSize := 20
page := 1
if params != nil {
if params.PageSize > 0 {
pageSize = params.PageSize
}
if params.Page > 0 {
page = params.Page
}
}
status.Results, err = s.getSuiteResults(tx, suiteID, page, pageSize)
if err != nil {
logr.Errorf("[sql.GetSuiteStatusByKey] Failed to retrieve results for suite_id=%d: %s", suiteID, err.Error())
}
// Populate Name and Group fields on each result
for _, result := range status.Results {
result.Name = name
result.Group = group
}
if err = tx.Commit(); err != nil {
return nil, err
}
return status, nil
}
// InsertSuiteResult adds the observed result for the specified suite into the store
func (s *Store) InsertSuiteResult(su *suite.Suite, result *suite.Result) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Get or create suite
suiteID, err := s.getSuiteID(tx, su)
if err != nil {
if errors.Is(err, common.ErrSuiteNotFound) {
// Suite doesn't exist in the database, insert it
if suiteID, err = s.insertSuite(tx, su); err != nil {
logr.Errorf("[sql.InsertSuiteResult] Failed to create suite with key=%s: %s", su.Key(), err.Error())
return err
}
} else {
logr.Errorf("[sql.InsertSuiteResult] Failed to retrieve id of suite with key=%s: %s", su.Key(), err.Error())
return err
}
}
// Insert suite result
var suiteResultID int64
err = tx.QueryRow(`
INSERT INTO suite_results (suite_id, success, errors, duration, timestamp)
VALUES ($1, $2, $3, $4, $5)
RETURNING suite_result_id
`,
suiteID,
result.Success,
strings.Join(result.Errors, arraySeparator),
result.Duration.Nanoseconds(),
result.Timestamp.UTC(), // timestamp is the start time
).Scan(&suiteResultID)
if err != nil {
return err
}
// For each endpoint result in the suite, we need to store them
for _, epResult := range result.EndpointResults {
// Create a temporary endpoint object for storage
ep := &endpoint.Endpoint{
Name: epResult.Name,
Group: su.Group,
}
// Get or create the endpoint (without suite linkage in endpoints table)
epID, err := s.getEndpointID(tx, ep)
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
// Endpoint doesn't exist, create it
if epID, err = s.insertEndpoint(tx, ep); err != nil {
logr.Errorf("[sql.InsertSuiteResult] Failed to create endpoint %s: %s", epResult.Name, err.Error())
continue
}
} else {
logr.Errorf("[sql.InsertSuiteResult] Failed to get endpoint %s: %s", epResult.Name, err.Error())
continue
}
}
// InsertEndpointResult the endpoint result with suite linkage
err = s.insertEndpointResultWithSuiteID(tx, epID, epResult, &suiteResultID)
if err != nil {
logr.Errorf("[sql.InsertSuiteResult] Failed to insert endpoint result for %s: %s", epResult.Name, err.Error())
}
}
// Clean up old suite results
numberOfResults, err := s.getNumberOfSuiteResultsByID(tx, suiteID)
if err != nil {
logr.Errorf("[sql.InsertSuiteResult] Failed to retrieve total number of results for suite with key=%s: %s", su.Key(), err.Error())
} else {
if numberOfResults > int64(s.maximumNumberOfResults+resultsAboveMaximumCleanUpThreshold) {
if err = s.deleteOldSuiteResults(tx, suiteID); err != nil {
logr.Errorf("[sql.InsertSuiteResult] Failed to delete old results for suite with key=%s: %s", su.Key(), err.Error())
}
}
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
// DeleteAllSuiteStatusesNotInKeys removes all suite statuses that are not within the keys provided
func (s *Store) DeleteAllSuiteStatusesNotInKeys(keys []string) int {
logr.Debugf("[sql.DeleteAllSuiteStatusesNotInKeys] Called with %d keys", len(keys))
if len(keys) == 0 {
// Delete all suites
logr.Debugf("[sql.DeleteAllSuiteStatusesNotInKeys] No keys provided, deleting all suites")
result, err := s.db.Exec("DELETE FROM suites")
if err != nil {
logr.Errorf("[sql.DeleteAllSuiteStatusesNotInKeys] Failed to delete all suites: %s", err.Error())
return 0
}
rowsAffected, _ := result.RowsAffected()
return int(rowsAffected)
}
args := make([]interface{}, 0, len(keys))
query := "DELETE FROM suites WHERE suite_key NOT IN ("
for i := range keys {
if i > 0 {
query += ","
}
query += fmt.Sprintf("$%d", i+1)
args = append(args, keys[i])
}
query += ")"
// First, let's see what we're about to delete
checkQuery := "SELECT suite_key FROM suites WHERE suite_key NOT IN ("
for i := range keys {
if i > 0 {
checkQuery += ","
}
checkQuery += fmt.Sprintf("$%d", i+1)
}
checkQuery += ")"
rows, err := s.db.Query(checkQuery, args...)
if err == nil {
defer rows.Close()
var deletedKeys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err == nil {
deletedKeys = append(deletedKeys, key)
}
}
if len(deletedKeys) > 0 {
logr.Infof("[sql.DeleteAllSuiteStatusesNotInKeys] Deleting suites with keys: %v", deletedKeys)
}
}
result, err := s.db.Exec(query, args...)
if err != nil {
logr.Errorf("[sql.DeleteAllSuiteStatusesNotInKeys] Failed to delete suites: %s", err.Error())
return 0
}
rowsAffected, _ := result.RowsAffected()
return int(rowsAffected)
}
// Suite helper methods
// getSuiteID retrieves the suite ID from the database by its key
func (s *Store) getSuiteID(tx *sql.Tx, su *suite.Suite) (int64, error) {
var id int64
err := tx.QueryRow("SELECT suite_id FROM suites WHERE suite_key = $1", su.Key()).Scan(&id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, common.ErrSuiteNotFound
}
return 0, err
}
return id, nil
}
// insertSuite inserts a suite in the store and returns the generated id
func (s *Store) insertSuite(tx *sql.Tx, su *suite.Suite) (int64, error) {
var id int64
err := tx.QueryRow(
"INSERT INTO suites (suite_key, suite_name, suite_group) VALUES ($1, $2, $3) RETURNING suite_id",
su.Key(),
su.Name,
su.Group,
).Scan(&id)
if err != nil {
return 0, err
}
return id, nil
}
// getSuiteResults retrieves paginated suite results
func (s *Store) getSuiteResults(tx *sql.Tx, suiteID int64, page, pageSize int) ([]*suite.Result, error) {
rows, err := tx.Query(`
SELECT suite_result_id, success, errors, duration, timestamp
FROM suite_results
WHERE suite_id = $1
ORDER BY suite_result_id DESC
LIMIT $2 OFFSET $3
`,
suiteID,
pageSize,
(page-1)*pageSize,
)
if err != nil {
logr.Errorf("[sql.getSuiteResults] Query failed: %v", err)
return nil, err
}
defer rows.Close()
type suiteResultData struct {
result *suite.Result
id int64
}
var resultsData []suiteResultData
for rows.Next() {
result := &suite.Result{
EndpointResults: []*endpoint.Result{},
}
var suiteResultID int64
var joinedErrors string
var nanoseconds int64
err = rows.Scan(&suiteResultID, &result.Success, &joinedErrors, &nanoseconds, &result.Timestamp)
if err != nil {
logr.Errorf("[sql.getSuiteResults] Failed to scan suite result: %s", err.Error())
continue
}
result.Duration = time.Duration(nanoseconds)
if len(joinedErrors) > 0 {
result.Errors = strings.Split(joinedErrors, arraySeparator)
}
// Store both result and ID together
resultsData = append(resultsData, suiteResultData{
result: result,
id: suiteResultID,
})
}
// Reverse the results to get chronological order (oldest to newest)
for i := len(resultsData)/2 - 1; i >= 0; i-- {
opp := len(resultsData) - 1 - i
resultsData[i], resultsData[opp] = resultsData[opp], resultsData[i]
}
// Fetch endpoint results for each suite result
for _, data := range resultsData {
result := data.result
resultID := data.id
// Query endpoint results for this suite result
epRows, err := tx.Query(`
SELECT
er.endpoint_result_id,
e.endpoint_name,
er.success,
er.errors,
er.duration,
er.timestamp
FROM endpoint_results er
JOIN endpoints e ON er.endpoint_id = e.endpoint_id
WHERE er.suite_result_id = $1
ORDER BY er.endpoint_result_id
`, resultID)
if err != nil {
logr.Errorf("[sql.getSuiteResults] Failed to get endpoint results for suite_result_id=%d: %s", resultID, err.Error())
continue
}
// Map to store endpoint results by their ID for condition lookup
epResultMap := make(map[int64]*endpoint.Result)
epCount := 0
for epRows.Next() {
epCount++
var epResultID int64
var name string
var success bool
var joinedErrors string
var duration int64
var timestamp time.Time
err = epRows.Scan(&epResultID, &name, &success, &joinedErrors, &duration, ×tamp)
if err != nil {
logr.Errorf("[sql.getSuiteResults] Failed to scan endpoint result: %s", err.Error())
continue
}
epResult := &endpoint.Result{
Name: name,
Success: success,
Duration: time.Duration(duration),
Timestamp: timestamp,
ConditionResults: []*endpoint.ConditionResult{}, // Initialize empty slice
}
if len(joinedErrors) > 0 {
epResult.Errors = strings.Split(joinedErrors, arraySeparator)
}
epResultMap[epResultID] = epResult
result.EndpointResults = append(result.EndpointResults, epResult)
}
epRows.Close()
// Fetch condition results for all endpoint results in this suite result
if len(epResultMap) > 0 {
args := make([]interface{}, 0, len(epResultMap))
condQuery := `SELECT endpoint_result_id, condition, success
FROM endpoint_result_conditions
WHERE endpoint_result_id IN (`
index := 1
for epResultID := range epResultMap {
condQuery += "$" + strconv.Itoa(index) + ","
args = append(args, epResultID)
index++
}
condQuery = condQuery[:len(condQuery)-1] + ")"
condRows, err := tx.Query(condQuery, args...)
if err != nil {
logr.Errorf("[sql.getSuiteResults] Failed to get condition results for suite_result_id=%d: %s", resultID, err.Error())
} else {
condCount := 0
for condRows.Next() {
condCount++
conditionResult := &endpoint.ConditionResult{}
var epResultID int64
if err = condRows.Scan(&epResultID, &conditionResult.Condition, &conditionResult.Success); err != nil {
logr.Errorf("[sql.getSuiteResults] Failed to scan condition result: %s", err.Error())
continue
}
if epResult, exists := epResultMap[epResultID]; exists {
epResult.ConditionResults = append(epResult.ConditionResults, conditionResult)
}
}
condRows.Close()
if condCount > 0 {
logr.Debugf("[sql.getSuiteResults] Found %d condition results for suite_result_id=%d", condCount, resultID)
}
}
}
if epCount > 0 {
logr.Debugf("[sql.getSuiteResults] Found %d endpoint results for suite_result_id=%d", epCount, resultID)
}
}
// Extract just the results for return
var results []*suite.Result
for _, data := range resultsData {
results = append(results, data.result)
}
return results, nil
}
// getNumberOfSuiteResultsByID gets the count of results for a suite
func (s *Store) getNumberOfSuiteResultsByID(tx *sql.Tx, suiteID int64) (int64, error) {
var count int64
err := tx.QueryRow("SELECT COUNT(1) FROM suite_results WHERE suite_id = $1", suiteID).Scan(&count)
return count, err
}
// deleteOldSuiteResults deletes old suite results beyond the maximum
func (s *Store) deleteOldSuiteResults(tx *sql.Tx, suiteID int64) error {
_, err := tx.Exec(`
DELETE FROM suite_results
WHERE suite_id = $1
AND suite_result_id NOT IN (
SELECT suite_result_id
FROM suite_results
WHERE suite_id = $1
ORDER BY suite_result_id DESC
LIMIT $2
)
`,
suiteID,
s.maximumNumberOfResults,
)
return err
}
================================================
FILE: storage/store/sql/sql_test.go
================================================
package sql
import (
"errors"
"fmt"
"testing"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
)
var (
firstCondition = endpoint.Condition("[STATUS] == 200")
secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
now = time.Now()
testEndpoint = endpoint.Endpoint{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Success: true,
Timestamp: now,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = endpoint.Result{
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: now,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
func TestNewStore(t *testing.T) {
if _, err := NewStore("", t.TempDir()+"/TestNewStore.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents); !errors.Is(err, ErrDatabaseDriverNotSpecified) {
t.Error("expected error due to blank driver parameter")
}
if _, err := NewStore("sqlite", "", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents); !errors.Is(err, ErrPathNotSpecified) {
t.Error("expected error due to blank path parameter")
}
if store, err := NewStore("sqlite", t.TempDir()+"/TestNewStore.db", true, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents); err != nil {
t.Error("shouldn't have returned any error, got", err.Error())
} else {
_ = store.db.Close()
}
}
func TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpOldUptimeEntriesProperly.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
now := time.Now().Truncate(time.Hour)
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-5 * time.Hour), Success: true})
tx, _ := store.db.Begin()
oldest, _ := store.getAgeOfOldestEndpointUptimeEntry(tx, 1)
_ = tx.Commit()
if oldest.Truncate(time.Hour) != 5*time.Hour {
t.Errorf("oldest endpoint uptime entry should've been ~5 hours old, was %s", oldest)
}
// The oldest cache entry should remain at ~5 hours old, because this entry is more recent
store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-3 * time.Hour), Success: true})
tx, _ = store.db.Begin()
oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)
_ = tx.Commit()
if oldest.Truncate(time.Hour) != 5*time.Hour {
t.Errorf("oldest endpoint uptime entry should've been ~5 hours old, was %s", oldest)
}
// The oldest cache entry should now become at ~8 hours old, because this entry is older
store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-8 * time.Hour), Success: true})
tx, _ = store.db.Begin()
oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)
_ = tx.Commit()
if oldest.Truncate(time.Hour) != 8*time.Hour {
t.Errorf("oldest endpoint uptime entry should've been ~8 hours old, was %s", oldest)
}
// Since this is one hour before reaching the clean up threshold, the oldest entry should now be this one
store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeAgeCleanUpThreshold - time.Hour)), Success: true})
tx, _ = store.db.Begin()
oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)
_ = tx.Commit()
if oldest.Truncate(time.Hour) != uptimeAgeCleanUpThreshold-time.Hour {
t.Errorf("oldest endpoint uptime entry should've been ~%s hours old, was %s", uptimeAgeCleanUpThreshold-time.Hour, oldest)
}
// Since this entry is after the uptimeAgeCleanUpThreshold, both this entry as well as the previous
// one should be deleted since they both surpass uptimeRetention
store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeAgeCleanUpThreshold + time.Hour)), Success: true})
tx, _ = store.db.Begin()
oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)
_ = tx.Commit()
if oldest.Truncate(time.Hour) != 8*time.Hour {
t.Errorf("oldest endpoint uptime entry should've been ~8 hours old, was %s", oldest)
}
}
func TestStore_HourlyUptimeEntriesAreMergedIntoDailyUptimeEntriesProperly(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_HourlyUptimeEntriesAreMergedIntoDailyUptimeEntriesProperly.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
now := time.Now().Truncate(time.Hour)
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
scenarios := []struct {
numberOfHours int
expectedMaxUptimeEntries int64
}{
{numberOfHours: 1, expectedMaxUptimeEntries: 1},
{numberOfHours: 10, expectedMaxUptimeEntries: 10},
{numberOfHours: 50, expectedMaxUptimeEntries: 50},
{numberOfHours: 75, expectedMaxUptimeEntries: 75},
{numberOfHours: 99, expectedMaxUptimeEntries: 99},
{numberOfHours: 150, expectedMaxUptimeEntries: 100},
{numberOfHours: 300, expectedMaxUptimeEntries: 100},
{numberOfHours: 768, expectedMaxUptimeEntries: 100}, // 32 days (in hours), which means anything beyond that won't be persisted anyway
{numberOfHours: 1000, expectedMaxUptimeEntries: 100},
}
// Note that is not technically an accurate real world representation, because uptime entries are always added in
// the present, while this test is inserting results from the past to simulate long term uptime entries.
// Since we want to test the behavior and not the test itself, this is a "best effort" approach.
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("num-hours-%d-expected-max-entries-%d", scenario.numberOfHours, scenario.expectedMaxUptimeEntries), func(t *testing.T) {
for i := scenario.numberOfHours; i > 0; i-- {
//fmt.Printf("i: %d (%s)\n", i, now.Add(-time.Duration(i)*time.Hour))
// Create an uptime entry
err := store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-time.Duration(i) * time.Hour), Success: true})
if err != nil {
t.Log(err)
}
//// DEBUGGING: check number of uptime entries for endpoint
//tx, _ := store.db.Begin()
//numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1)
//if err != nil {
// t.Log(err)
//}
//_ = tx.Commit()
//t.Logf("i=%d; numberOfHours=%d; There are currently %d uptime entries for endpointID=%d", i, scenario.numberOfHours, numberOfUptimeEntriesForEndpoint, 1)
}
// check number of uptime entries for endpoint
tx, _ := store.db.Begin()
numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1)
if err != nil {
t.Log(err)
}
_ = tx.Commit()
//t.Logf("numberOfHours=%d; There are currently %d uptime entries for endpointID=%d", scenario.numberOfHours, numberOfUptimeEntriesForEndpoint, 1)
if scenario.expectedMaxUptimeEntries < numberOfUptimeEntriesForEndpoint {
t.Errorf("expected %d (uptime entries) to be smaller than %d", numberOfUptimeEntriesForEndpoint, scenario.expectedMaxUptimeEntries)
}
store.Clear()
})
}
}
func TestStore_getEndpointUptime(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpEventsAndResultsProperly.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Clear()
defer store.Close()
// Add 768 hourly entries (32 days)
// Daily entries should be merged from hourly entries automatically
for i := 768; i > 0; i-- {
err := store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: time.Now().Add(-time.Duration(i) * time.Hour), Duration: time.Second, Success: true})
if err != nil {
t.Log(err)
}
}
// Check the number of uptime entries
tx, _ := store.db.Begin()
numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1)
if err != nil {
t.Log(err)
}
if numberOfUptimeEntriesForEndpoint < 20 || numberOfUptimeEntriesForEndpoint > 200 {
t.Errorf("expected number of uptime entries to be between 20 and 200, got %d", numberOfUptimeEntriesForEndpoint)
}
// Retrieve uptime for the past 30d
uptime, avgResponseTime, err := store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now())
if err != nil {
t.Log(err)
}
_ = tx.Commit()
if avgResponseTime != time.Second {
t.Errorf("expected average response time to be %s, got %s", time.Second, avgResponseTime)
}
if uptime != 1 {
t.Errorf("expected uptime to be 1, got %f", uptime)
}
// Add a new unsuccessful result, which should impact the uptime
err = store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: time.Now(), Duration: time.Second, Success: false})
if err != nil {
t.Log(err)
}
// Retrieve uptime for the past 30d
tx, _ = store.db.Begin()
uptime, _, err = store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now())
if err != nil {
t.Log(err)
}
_ = tx.Commit()
if uptime == 1 {
t.Errorf("expected uptime to be less than 1, got %f", uptime)
}
// Retrieve uptime for the past 30d, but excluding the last 24h
// This is not a real use case as there is no way for users to exclude the last 24h, but this is a great way
// to ensure that hourly merging works as intended
tx, _ = store.db.Begin()
uptimeExcludingLast24h, _, err := store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now().Add(-24*time.Hour))
if err != nil {
t.Log(err)
}
_ = tx.Commit()
if uptimeExcludingLast24h == uptime {
t.Error("expected uptimeExcludingLast24h to to be different from uptime, got")
}
}
func TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpEventsAndResultsProperly.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Clear()
defer store.Close()
resultsCleanUpThreshold := store.maximumNumberOfResults + resultsAboveMaximumCleanUpThreshold
eventsCleanUpThreshold := store.maximumNumberOfEvents + eventsAboveMaximumCleanUpThreshold
for i := 0; i < resultsCleanUpThreshold+eventsCleanUpThreshold; i++ {
store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
ss, _ := store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, storage.DefaultMaximumNumberOfResults*5).WithEvents(1, storage.DefaultMaximumNumberOfEvents*5))
if len(ss.Results) > resultsCleanUpThreshold+1 {
t.Errorf("number of results shouldn't have exceeded %d, reached %d", resultsCleanUpThreshold, len(ss.Results))
}
if len(ss.Events) > eventsCleanUpThreshold+1 {
t.Errorf("number of events shouldn't have exceeded %d, reached %d", eventsCleanUpThreshold, len(ss.Events))
}
}
}
func TestStore_InsertWithCaching(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertWithCaching.db", true, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
// Add 2 results
store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
// Verify that they exist
endpointStatuses, _ := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {
t.Fatalf("expected 1 EndpointStatus, got %d", numberOfEndpointStatuses)
}
if len(endpointStatuses[0].Results) != 2 {
t.Fatalf("expected 2 results, got %d", len(endpointStatuses[0].Results))
}
// Add 2 more results
store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
// Verify that they exist
endpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {
t.Fatalf("expected 1 EndpointStatus, got %d", numberOfEndpointStatuses)
}
if len(endpointStatuses[0].Results) != 4 {
t.Fatalf("expected 4 results, got %d", len(endpointStatuses[0].Results))
}
// Clear the store, which should also clear the cache
store.Clear()
// Verify that they no longer exist
endpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 0 {
t.Fatalf("expected 0 EndpointStatus, got %d", numberOfEndpointStatuses)
}
}
func TestStore_Persistence(t *testing.T) {
path := t.TempDir() + "/TestStore_Persistence.db"
store, _ := NewStore("sqlite", path, false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 1h should've been 0.5, got %f", uptime)
}
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 24h should've been 0.5, got %f", uptime)
}
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 7d should've been 0.5, got %f", uptime)
}
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24*30), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 30d should've been 0.5, got %f", uptime)
}
ssFromOldStore, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, storage.DefaultMaximumNumberOfResults).WithEvents(1, storage.DefaultMaximumNumberOfEvents))
if ssFromOldStore == nil || ssFromOldStore.Group != "group" || ssFromOldStore.Name != "name" || len(ssFromOldStore.Events) != 3 || len(ssFromOldStore.Results) != 2 {
store.Close()
t.Fatal("sanity check failed")
}
store.Close()
store, _ = NewStore("sqlite", path, false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
ssFromNewStore, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, storage.DefaultMaximumNumberOfResults).WithEvents(1, storage.DefaultMaximumNumberOfEvents))
if ssFromNewStore == nil || ssFromNewStore.Group != "group" || ssFromNewStore.Name != "name" || len(ssFromNewStore.Events) != 3 || len(ssFromNewStore.Results) != 2 {
t.Fatal("failed sanity check")
}
if ssFromNewStore == ssFromOldStore {
t.Fatal("ss from the old and new store should have a different memory address")
}
for i := range ssFromNewStore.Events {
if ssFromNewStore.Events[i].Timestamp != ssFromOldStore.Events[i].Timestamp {
t.Error("new and old should've been the same")
}
if ssFromNewStore.Events[i].Type != ssFromOldStore.Events[i].Type {
t.Error("new and old should've been the same")
}
}
for i := range ssFromOldStore.Results {
if ssFromNewStore.Results[i].Timestamp != ssFromOldStore.Results[i].Timestamp {
t.Error("new and old should've been the same")
}
if ssFromNewStore.Results[i].Success != ssFromOldStore.Results[i].Success {
t.Error("new and old should've been the same")
}
if ssFromNewStore.Results[i].Connected != ssFromOldStore.Results[i].Connected {
t.Error("new and old should've been the same")
}
if ssFromNewStore.Results[i].IP != ssFromOldStore.Results[i].IP {
t.Error("new and old should've been the same")
}
if ssFromNewStore.Results[i].Hostname != ssFromOldStore.Results[i].Hostname {
t.Error("new and old should've been the same")
}
if ssFromNewStore.Results[i].HTTPStatus != ssFromOldStore.Results[i].HTTPStatus {
t.Error("new and old should've been the same")
}
if ssFromNewStore.Results[i].DNSRCode != ssFromOldStore.Results[i].DNSRCode {
t.Error("new and old should've been the same")
}
if len(ssFromNewStore.Results[i].Errors) != len(ssFromOldStore.Results[i].Errors) {
t.Error("new and old should've been the same")
} else {
for j := range ssFromOldStore.Results[i].Errors {
if ssFromNewStore.Results[i].Errors[j] != ssFromOldStore.Results[i].Errors[j] {
t.Error("new and old should've been the same")
}
}
}
if len(ssFromNewStore.Results[i].ConditionResults) != len(ssFromOldStore.Results[i].ConditionResults) {
t.Error("new and old should've been the same")
} else {
for j := range ssFromOldStore.Results[i].ConditionResults {
if ssFromNewStore.Results[i].ConditionResults[j].Condition != ssFromOldStore.Results[i].ConditionResults[j].Condition {
t.Error("new and old should've been the same")
}
if ssFromNewStore.Results[i].ConditionResults[j].Success != ssFromOldStore.Results[i].ConditionResults[j].Success {
t.Error("new and old should've been the same")
}
}
}
}
}
func TestStore_Save(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_Save.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
if store.Save() != nil {
t.Error("Save shouldn't do anything for this store")
}
}
// Note that are much more extensive tests in /storage/store/store_test.go.
// This test is simply an extra sanity check
func TestStore_SanityCheck(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_SanityCheck.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
endpointStatuses, _ := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())
if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {
t.Fatalf("expected 1 EndpointStatus, got %d", numberOfEndpointStatuses)
}
store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
// Both results inserted are for the same endpoint, therefore, the count shouldn't have increased
endpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())
if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {
t.Fatalf("expected 1 EndpointStatus, got %d", numberOfEndpointStatuses)
}
if hourlyAverageResponseTime, err := store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); err != nil {
t.Errorf("expected no error, got %v", err)
} else if len(hourlyAverageResponseTime) != 1 {
t.Errorf("expected 1 hour to have had a result in the past 24 hours, got %d", len(hourlyAverageResponseTime))
}
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); uptime != 0.5 {
t.Errorf("expected uptime of last 24h to be 0.5, got %f", uptime)
}
if averageResponseTime, _ := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); averageResponseTime != 450 {
t.Errorf("expected average response time of last 24h to be 450, got %d", averageResponseTime)
}
ss, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, 20).WithEvents(1, 20))
if ss == nil {
t.Fatalf("Store should've had key '%s', but didn't", testEndpoint.Key())
}
if len(ss.Events) != 3 {
t.Errorf("Endpoint '%s' should've had 3 events, got %d", ss.Name, len(ss.Events))
}
if len(ss.Results) != 2 {
t.Errorf("Endpoint '%s' should've had 2 results, got %d", ss.Name, len(ss.Results))
}
if deleted := store.DeleteAllEndpointStatusesNotInKeys([]string{"invalid-key-which-means-everything-should-get-deleted"}); deleted != 1 {
t.Errorf("%d entries should've been deleted, got %d", 1, deleted)
}
if deleted := store.DeleteAllEndpointStatusesNotInKeys([]string{}); deleted != 0 {
t.Errorf("There should've been no entries left to delete, got %d", deleted)
}
}
// TestStore_InvalidTransaction tests what happens if an invalid transaction is passed as parameter
func TestStore_InvalidTransaction(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InvalidTransaction.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
tx, _ := store.db.Begin()
tx.Commit()
if _, err := store.insertEndpoint(tx, &testEndpoint); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if err := store.insertEndpointEvent(tx, 1, endpoint.NewEventFromResult(&testSuccessfulResult)); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if err := store.insertEndpointResult(tx, 1, &testSuccessfulResult); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if err := store.insertConditionResults(tx, 1, testSuccessfulResult.ConditionResults); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if err := store.updateEndpointUptime(tx, 1, &testSuccessfulResult); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, err := store.getAllEndpointKeys(tx); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, err := store.getEndpointStatusByKey(tx, testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, 20)); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, err := store.getEndpointEventsByEndpointID(tx, 1, 1, 50); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, err := store.getEndpointResultsByEndpointID(tx, 1, 1, 50); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if err := store.deleteOldEndpointEvents(tx, 1); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if err := store.deleteOldEndpointResults(tx, 1); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, _, err := store.getEndpointUptime(tx, 1, time.Now(), time.Now()); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, err := store.getEndpointID(tx, &testEndpoint); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, err := store.getNumberOfEventsByEndpointID(tx, 1); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, err := store.getNumberOfResultsByEndpointID(tx, 1); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, err := store.getAgeOfOldestEndpointUptimeEntry(tx, 1); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
if _, err := store.getLastEndpointResultSuccessValue(tx, 1); err == nil {
t.Error("should've returned an error, because the transaction was already committed")
}
}
func TestStore_NoRows(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_NoRows.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
tx, _ := store.db.Begin()
defer tx.Rollback()
if _, err := store.getLastEndpointResultSuccessValue(tx, 1); !errors.Is(err, errNoRowsReturned) {
t.Errorf("should've %v, got %v", errNoRowsReturned, err)
}
if _, err := store.getAgeOfOldestEndpointUptimeEntry(tx, 1); !errors.Is(err, errNoRowsReturned) {
t.Errorf("should've %v, got %v", errNoRowsReturned, err)
}
}
// This tests very unlikely cases where a table is deleted.
func TestStore_BrokenSchema(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_BrokenSchema.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {
t.Fatal("expected no error, got", err.Error())
}
if _, err := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err != nil {
t.Fatal("expected no error, got", err.Error())
}
if _, err := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams()); err != nil {
t.Fatal("expected no error, got", err.Error())
}
// Break
_, _ = store.db.Exec("DROP TABLE endpoints")
// And now we'll try to insert something in our broken schema
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err == nil {
t.Fatal("expected an error")
}
if _, err := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {
t.Fatal("expected an error")
}
if _, err := store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {
t.Fatal("expected an error")
}
if _, err := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams()); err == nil {
t.Fatal("expected an error")
}
if _, err := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {
t.Fatal("expected an error")
}
if _, err := store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams()); err == nil {
t.Fatal("expected an error")
}
// Repair
if err := store.createSchema(); err != nil {
t.Fatal("schema should've been repaired")
}
store.Clear()
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {
t.Fatal("expected no error, got", err.Error())
}
// Break
_, _ = store.db.Exec("DROP TABLE endpoint_events")
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {
t.Fatal("expected no error, because this should silently fails, got", err.Error())
}
if _, err := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 1).WithEvents(1, 1)); err != nil {
t.Fatal("expected no error, because this should silently fail, got", err.Error())
}
// Repair
if err := store.createSchema(); err != nil {
t.Fatal("schema should've been repaired")
}
store.Clear()
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {
t.Fatal("expected no error, got", err.Error())
}
// Break
_, _ = store.db.Exec("DROP TABLE endpoint_results")
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err == nil {
t.Fatal("expected an error")
}
if _, err := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 1).WithEvents(1, 1)); err == nil {
t.Fatal("expected an error")
}
// Repair
if err := store.createSchema(); err != nil {
t.Fatal("schema should've been repaired")
}
store.Clear()
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {
t.Fatal("expected no error, got", err.Error())
}
// Break
_, _ = store.db.Exec("DROP TABLE endpoint_result_conditions")
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err == nil {
t.Fatal("expected an error")
}
// Repair
if err := store.createSchema(); err != nil {
t.Fatal("schema should've been repaired")
}
store.Clear()
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {
t.Fatal("expected no error, got", err.Error())
}
// Break
_, _ = store.db.Exec("DROP TABLE endpoint_uptimes")
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {
t.Fatal("expected no error, because this should silently fails, got", err.Error())
}
if _, err := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {
t.Fatal("expected an error")
}
if _, err := store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {
t.Fatal("expected an error")
}
if _, err := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {
t.Fatal("expected an error")
}
}
func TestCacheKey(t *testing.T) {
scenarios := []struct {
endpointKey string
params paging.EndpointStatusParams
overrideCacheKey string
expectedCacheKey string
wantErr bool
}{
{
endpointKey: "simple",
params: paging.EndpointStatusParams{EventsPage: 1, EventsPageSize: 2, ResultsPage: 3, ResultsPageSize: 4},
expectedCacheKey: "simple-1-2-3-4",
wantErr: false,
},
{
endpointKey: "with-hyphen",
params: paging.EndpointStatusParams{EventsPage: 0, EventsPageSize: 0, ResultsPage: 1, ResultsPageSize: 20},
expectedCacheKey: "with-hyphen-0-0-1-20",
wantErr: false,
},
{
endpointKey: "with-multiple-hyphens",
params: paging.EndpointStatusParams{EventsPage: 0, EventsPageSize: 0, ResultsPage: 2, ResultsPageSize: 20},
expectedCacheKey: "with-multiple-hyphens-0-0-2-20",
wantErr: false,
},
{
overrideCacheKey: "invalid-a-2-3-4",
wantErr: true,
},
{
overrideCacheKey: "invalid-1-a-3-4",
wantErr: true,
},
{
overrideCacheKey: "invalid-1-2-a-4",
wantErr: true,
},
{
overrideCacheKey: "invalid-1-2-3-a",
wantErr: true,
},
{
overrideCacheKey: "notenoughhyphen1-2-3-4",
wantErr: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.expectedCacheKey+scenario.overrideCacheKey, func(t *testing.T) {
var cacheKey string
if len(scenario.overrideCacheKey) > 0 {
cacheKey = scenario.overrideCacheKey
} else {
cacheKey = generateCacheKey(scenario.endpointKey, &scenario.params)
if cacheKey != scenario.expectedCacheKey {
t.Errorf("expected %s, got %s", scenario.expectedCacheKey, cacheKey)
}
}
extractedEndpointKey, extractedParams, err := extractKeyAndParamsFromCacheKey(cacheKey)
if (err != nil) != scenario.wantErr {
t.Errorf("expected error %v, got %v", scenario.wantErr, err)
return
}
if err != nil {
// If there's an error, we don't need to check the extracted values
return
}
if extractedEndpointKey != scenario.endpointKey {
t.Errorf("expected endpointKey %s, got %s", scenario.endpointKey, extractedEndpointKey)
}
if extractedParams.EventsPage != scenario.params.EventsPage {
t.Errorf("expected EventsPage %d, got %d", scenario.params.EventsPage, extractedParams.EventsPage)
}
if extractedParams.EventsPageSize != scenario.params.EventsPageSize {
t.Errorf("expected EventsPageSize %d, got %d", scenario.params.EventsPageSize, extractedParams.EventsPageSize)
}
if extractedParams.ResultsPage != scenario.params.ResultsPage {
t.Errorf("expected ResultsPage %d, got %d", scenario.params.ResultsPage, extractedParams.ResultsPage)
}
if extractedParams.ResultsPageSize != scenario.params.ResultsPageSize {
t.Errorf("expected ResultsPageSize %d, got %d", scenario.params.ResultsPageSize, extractedParams.ResultsPageSize)
}
})
}
}
func TestTriggeredEndpointAlertsPersistence(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestTriggeredEndpointAlertsPersistence.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
yes, desc := false, "description"
ep := testEndpoint
ep.NumberOfSuccessesInARow = 0
alrt := &alert.Alert{
Type: alert.TypePagerDuty,
Enabled: &yes,
FailureThreshold: 4,
SuccessThreshold: 2,
Description: &desc,
SendOnResolved: &yes,
Triggered: true,
ResolveKey: "1234567",
}
// Alert just triggered, so NumberOfSuccessesInARow is 0
if err := store.UpsertTriggeredEndpointAlert(&ep, alrt); err != nil {
t.Fatal("expected no error, got", err.Error())
}
exists, resolveKey, numberOfSuccessesInARow, err := store.GetTriggeredEndpointAlert(&ep, alrt)
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
if !exists {
t.Error("expected triggered alert to exist")
}
if resolveKey != alrt.ResolveKey {
t.Errorf("expected resolveKey %s, got %s", alrt.ResolveKey, resolveKey)
}
if numberOfSuccessesInARow != ep.NumberOfSuccessesInARow {
t.Errorf("expected persisted NumberOfSuccessesInARow to be %d, got %d", ep.NumberOfSuccessesInARow, numberOfSuccessesInARow)
}
// Endpoint just had a successful evaluation, so NumberOfSuccessesInARow is now 1
ep.NumberOfSuccessesInARow++
if err := store.UpsertTriggeredEndpointAlert(&ep, alrt); err != nil {
t.Fatal("expected no error, got", err.Error())
}
exists, resolveKey, numberOfSuccessesInARow, err = store.GetTriggeredEndpointAlert(&ep, alrt)
if err != nil {
t.Error("expected no error, got", err.Error())
}
if !exists {
t.Error("expected triggered alert to exist")
}
if resolveKey != alrt.ResolveKey {
t.Errorf("expected resolveKey %s, got %s", alrt.ResolveKey, resolveKey)
}
if numberOfSuccessesInARow != ep.NumberOfSuccessesInARow {
t.Errorf("expected persisted NumberOfSuccessesInARow to be %d, got %d", ep.NumberOfSuccessesInARow, numberOfSuccessesInARow)
}
// Simulate the endpoint having another successful evaluation, which means the alert is now resolved,
// and we should delete the triggered alert from the store
ep.NumberOfSuccessesInARow++
if err := store.DeleteTriggeredEndpointAlert(&ep, alrt); err != nil {
t.Fatal("expected no error, got", err.Error())
}
exists, _, _, err = store.GetTriggeredEndpointAlert(&ep, alrt)
if err != nil {
t.Error("expected no error, got", err.Error())
}
if exists {
t.Error("expected triggered alert to no longer exist as it has been deleted")
}
}
func TestStore_DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_DeleteAllTriggeredAlertsNotInChecksumsByEndpoint.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
yes, desc := false, "description"
ep1 := testEndpoint
ep1.Name = "ep1"
ep2 := testEndpoint
ep2.Name = "ep2"
alert1 := alert.Alert{
Type: alert.TypePagerDuty,
Enabled: &yes,
FailureThreshold: 4,
SuccessThreshold: 2,
Description: &desc,
SendOnResolved: &yes,
Triggered: true,
ResolveKey: "1234567",
}
alert2 := alert1
alert2.Type, alert2.ResolveKey = alert.TypeSlack, ""
alert3 := alert2
if err := store.UpsertTriggeredEndpointAlert(&ep1, &alert1); err != nil {
t.Fatal("expected no error, got", err.Error())
}
if err := store.UpsertTriggeredEndpointAlert(&ep1, &alert2); err != nil {
t.Fatal("expected no error, got", err.Error())
}
if err := store.UpsertTriggeredEndpointAlert(&ep2, &alert3); err != nil {
t.Fatal("expected no error, got", err.Error())
}
if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert1); !exists {
t.Error("expected alert1 to have been deleted")
}
if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert2); !exists {
t.Error("expected alert2 to exist for ep1")
}
if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep2, &alert3); !exists {
t.Error("expected alert3 to exist for ep2")
}
// Now we simulate the alert configuration being updated, and the alert being resolved
if deleted := store.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(&ep1, []string{alert2.Checksum()}); deleted != 1 {
t.Errorf("expected 1 triggered alert to be deleted, got %d", deleted)
}
if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert1); exists {
t.Error("expected alert1 to have been deleted")
}
if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert2); !exists {
t.Error("expected alert2 to exist for ep1")
}
if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep2, &alert3); !exists {
t.Error("expected alert3 to exist for ep2")
}
// Now let's just assume all alerts for ep1 were removed
if deleted := store.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(&ep1, []string{}); deleted != 1 {
t.Errorf("expected 1 triggered alert to be deleted, got %d", deleted)
}
// Make sure the alert for ep2 still exists
if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep2, &alert3); !exists {
t.Error("expected alert3 to exist for ep2")
}
}
func TestStore_HasEndpointStatusNewerThan(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_HasEndpointStatusNewerThan.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
defer store.Close()
// InsertEndpointResult an endpoint status
if err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {
t.Fatal("expected no error, got", err.Error())
}
// Check if it has a status newer than 1 hour ago
hasNewerStatus, err := store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-time.Hour))
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
if !hasNewerStatus {
t.Error("expected to have a newer status")
}
// Check if it has a status newer than 2 days ago
hasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-48*time.Hour))
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
if !hasNewerStatus {
t.Error("expected to have a newer status")
}
// Check if there's a status newer than 1 hour in the future (silly test, but it should work)
hasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(time.Hour))
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
if hasNewerStatus {
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 := range 50 {
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)
}
================================================
FILE: storage/store/store.go
================================================
package store
import (
"context"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/gatus/v5/storage/store/memory"
"github.com/TwiN/gatus/v5/storage/store/sql"
"github.com/TwiN/logr"
)
// Store is the interface that each store should implement
type Store interface {
// GetAllEndpointStatuses returns the JSON encoding of all monitored endpoint.Status
// with a subset of endpoint.Result defined by the page and pageSize parameters
GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error)
// GetAllSuiteStatuses returns all monitored suite statuses
GetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.Status, error)
// GetEndpointStatus returns the endpoint status for a given endpoint name in the given group
GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*endpoint.Status, error)
// GetEndpointStatusByKey returns the endpoint status for a given key
GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error)
// GetSuiteStatusByKey returns the suite status for a given key
GetSuiteStatusByKey(key string, params *paging.SuiteStatusParams) (*suite.Status, error)
// GetUptimeByKey returns the uptime percentage during a time range
GetUptimeByKey(key string, from, to time.Time) (float64, error)
// GetAverageResponseTimeByKey returns the average response time in milliseconds (value) during a time range
GetAverageResponseTimeByKey(key string, from, to time.Time) (int, error)
// GetHourlyAverageResponseTimeByKey returns a map of hourly (key) average response time in milliseconds (value) during a time range
GetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error)
// InsertEndpointResult adds the observed result for the specified endpoint into the store
InsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Result) error
// InsertSuiteResult adds the observed result for the specified suite into the store
InsertSuiteResult(s *suite.Suite, result *suite.Result) error
// DeleteAllEndpointStatusesNotInKeys removes all Status that are not within the keys provided
//
// Used to delete endpoints that have been persisted but are no longer part of the configured endpoints
DeleteAllEndpointStatusesNotInKeys(keys []string) int
// DeleteAllSuiteStatusesNotInKeys removes all suite statuses that are not within the keys provided
DeleteAllSuiteStatusesNotInKeys(keys []string) int
// GetTriggeredEndpointAlert returns whether the triggered alert for the specified endpoint as well as the necessary information to resolve it
GetTriggeredEndpointAlert(ep *endpoint.Endpoint, alert *alert.Alert) (exists bool, resolveKey string, numberOfSuccessesInARow int, err error)
// UpsertTriggeredEndpointAlert inserts/updates a triggered alert for an endpoint
// Used for persistence of triggered alerts across application restarts
UpsertTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error
// DeleteTriggeredEndpointAlert deletes a triggered alert for an endpoint
DeleteTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error
// DeleteAllTriggeredAlertsNotInChecksumsByEndpoint removes all triggered alerts owned by an endpoint whose alert
// configurations are not provided in the checksums list.
// This prevents triggered alerts that have been removed or modified from lingering in the database.
DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int
// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp
HasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error)
// Clear deletes everything from the store
Clear()
// Save persists the data if and where it needs to be persisted
Save() error
// Close terminates every connection and closes the store, if applicable.
// Should only be used before stopping the application.
Close()
}
// TODO: add method to check state of store (by keeping track of silent errors)
var (
// Validate interface implementation on compile
_ Store = (*memory.Store)(nil)
_ Store = (*sql.Store)(nil)
)
var (
store Store
// initialized keeps track of whether the storage provider was initialized
// Because store.Store is an interface, a nil check wouldn't be sufficient, so instead of doing reflection
// every single time Get is called, we'll just lazily keep track of its existence through this variable
initialized bool
ctx context.Context
cancelFunc context.CancelFunc
)
func Get() Store {
if !initialized {
// This only happens in tests
logr.Info("[store.Get] Provider requested before it was initialized, automatically initializing")
err := Initialize(nil)
if err != nil {
panic("failed to automatically initialize store: " + err.Error())
}
}
return store
}
// Initialize instantiates the storage provider based on the Config provider
func Initialize(cfg *storage.Config) error {
initialized = true
var err error
if cancelFunc != nil {
// Stop the active autoSave task, if there's already one
cancelFunc()
}
if cfg == nil {
// This only happens in tests
logr.Warn("[store.Initialize] nil storage config passed as parameter. This should only happen in tests. Defaulting to an empty config.")
cfg = &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
}
}
if len(cfg.Path) == 0 && cfg.Type != storage.TypePostgres {
logr.Infof("[store.Initialize] Creating storage provider of type=%s", cfg.Type)
}
ctx, cancelFunc = context.WithCancel(context.Background())
switch cfg.Type {
case storage.TypeSQLite, storage.TypePostgres:
store, err = sql.NewStore(string(cfg.Type), cfg.Path, cfg.Caching, cfg.MaximumNumberOfResults, cfg.MaximumNumberOfEvents)
if err != nil {
return err
}
case storage.TypeMemory:
fallthrough
default:
store, _ = memory.NewStore(cfg.MaximumNumberOfResults, cfg.MaximumNumberOfEvents)
}
return nil
}
// autoSave automatically calls the Save function of the provider at every interval
func autoSave(ctx context.Context, store Store, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logr.Info("[store.autoSave] Stopping active job")
return
case <-ticker.C:
logr.Info("[store.autoSave] Saving")
if err := store.Save(); err != nil {
logr.Errorf("[store.autoSave] Save failed: %s", err.Error())
}
}
}
}
================================================
FILE: storage/store/store_bench_test.go
================================================
package store
import (
"strconv"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/gatus/v5/storage/store/memory"
"github.com/TwiN/gatus/v5/storage/store/sql"
)
func BenchmarkStore_GetAllEndpointStatuses(b *testing.B) {
memoryStore, err := memory.NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
sqliteStore, err := sql.NewStore("sqlite", b.TempDir()+"/BenchmarkStore_GetAllEndpointStatuses.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
defer sqliteStore.Close()
type Scenario struct {
Name string
Store Store
Parallel bool
}
scenarios := []Scenario{
{
Name: "memory",
Store: memoryStore,
Parallel: false,
},
{
Name: "memory-parallel",
Store: memoryStore,
Parallel: true,
},
{
Name: "sqlite",
Store: sqliteStore,
Parallel: false,
},
{
Name: "sqlite-parallel",
Store: sqliteStore,
Parallel: true,
},
}
for _, scenario := range scenarios {
numberOfEndpoints := []int{10, 25, 50, 100}
for _, numberOfEndpointsToCreate := range numberOfEndpoints {
// Create endpoints and insert results
for i := range numberOfEndpointsToCreate {
ep := testEndpoint
ep.Name = "endpoint" + strconv.Itoa(i)
// InsertEndpointResult 20 results for each endpoint
for range 20 {
scenario.Store.InsertEndpointResult(&ep, &testSuccessfulResult)
}
}
// Run the scenarios
b.Run(scenario.Name+"-with-"+strconv.Itoa(numberOfEndpointsToCreate)+"-endpoints", func(b *testing.B) {
if scenario.Parallel {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, _ = scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
}
})
} else {
for n := 0; n < b.N; n++ {
_, _ = scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
}
}
b.ReportAllocs()
})
scenario.Store.Clear()
}
}
}
func BenchmarkStore_Insert(b *testing.B) {
memoryStore, err := memory.NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
sqliteStore, err := sql.NewStore("sqlite", b.TempDir()+"/BenchmarkStore_Insert.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
defer sqliteStore.Close()
type Scenario struct {
Name string
Store Store
Parallel bool
}
scenarios := []Scenario{
{
Name: "memory",
Store: memoryStore,
Parallel: false,
},
{
Name: "memory-parallel",
Store: memoryStore,
Parallel: true,
},
{
Name: "sqlite",
Store: sqliteStore,
Parallel: false,
},
{
Name: "sqlite-parallel",
Store: sqliteStore,
Parallel: false,
},
}
for _, scenario := range scenarios {
b.Run(scenario.Name, func(b *testing.B) {
if scenario.Parallel {
b.RunParallel(func(pb *testing.PB) {
n := 0
for pb.Next() {
var result endpoint.Result
if n%10 == 0 {
result = testUnsuccessfulResult
} else {
result = testSuccessfulResult
}
result.Timestamp = time.Now()
scenario.Store.InsertEndpointResult(&testEndpoint, &result)
n++
}
})
} else {
for n := 0; n < b.N; n++ {
var result endpoint.Result
if n%10 == 0 {
result = testUnsuccessfulResult
} else {
result = testSuccessfulResult
}
result.Timestamp = time.Now()
scenario.Store.InsertEndpointResult(&testEndpoint, &result)
}
}
b.ReportAllocs()
scenario.Store.Clear()
})
}
}
func BenchmarkStore_GetEndpointStatusByKey(b *testing.B) {
memoryStore, err := memory.NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
sqliteStore, err := sql.NewStore("sqlite", b.TempDir()+"/BenchmarkStore_GetEndpointStatusByKey.db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
b.Fatal("failed to create store:", err.Error())
}
defer sqliteStore.Close()
type Scenario struct {
Name string
Store Store
Parallel bool
}
scenarios := []Scenario{
{
Name: "memory",
Store: memoryStore,
Parallel: false,
},
{
Name: "memory-parallel",
Store: memoryStore,
Parallel: true,
},
{
Name: "sqlite",
Store: sqliteStore,
Parallel: false,
},
{
Name: "sqlite-parallel",
Store: sqliteStore,
Parallel: true,
},
}
for _, scenario := range scenarios {
for range 50 {
scenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
}
b.Run(scenario.Name, func(b *testing.B) {
if scenario.Parallel {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, 20))
}
})
} else {
for n := 0; n < b.N; n++ {
scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, 20))
}
}
b.ReportAllocs()
})
scenario.Store.Clear()
}
}
================================================
FILE: storage/store/store_test.go
================================================
package store
import (
"errors"
"path/filepath"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/gatus/v5/storage/store/memory"
"github.com/TwiN/gatus/v5/storage/store/sql"
)
var (
firstCondition = endpoint.Condition("[STATUS] == 200")
secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
now = time.Now().Truncate(time.Hour)
testEndpoint = endpoint.Endpoint{
Name: "name",
Group: "group",
URL: "https://example.org/what/ever",
Method: "GET",
Body: "body",
Interval: 30 * time.Second,
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
Alerts: nil,
NumberOfFailuresInARow: 0,
NumberOfSuccessesInARow: 0,
}
testSuccessfulResult = endpoint.Result{
Timestamp: now,
Success: true,
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: nil,
Connected: true,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: true,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: true,
},
},
}
testUnsuccessfulResult = endpoint.Result{
Timestamp: now,
Success: false,
Hostname: "example.org",
IP: "127.0.0.1",
HTTPStatus: 200,
Errors: []string{"error-1", "error-2"},
Connected: true,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[RESPONSE_TIME] < 500",
Success: false,
},
{
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
Success: false,
},
},
}
)
type Scenario struct {
Name string
Store Store
}
func initStoresAndBaseScenarios(t *testing.T, testName string) []*Scenario {
memoryStore, err := memory.NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
t.Fatal("failed to create store:", err.Error())
}
sqliteStore, err := sql.NewStore("sqlite", t.TempDir()+"/"+testName+".db", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
t.Fatal("failed to create store:", err.Error())
}
sqliteStoreWithCaching, err := sql.NewStore("sqlite", t.TempDir()+"/"+testName+"-with-caching.db", true, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)
if err != nil {
t.Fatal("failed to create store:", err.Error())
}
return []*Scenario{
{
Name: "memory",
Store: memoryStore,
},
{
Name: "sqlite",
Store: sqliteStore,
},
{
Name: "sqlite-with-caching",
Store: sqliteStoreWithCaching,
},
}
}
func cleanUp(scenarios []*Scenario) {
for _, scenario := range scenarios {
scenario.Store.Close()
}
}
func TestStore_GetEndpointStatusByKey(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetEndpointStatusByKey")
defer cleanUp(scenarios)
firstResult := testSuccessfulResult
firstResult.Timestamp = now.Add(-time.Minute)
secondResult := testUnsuccessfulResult
secondResult.Timestamp = now
thirdResult := testSuccessfulResult
thirdResult.Timestamp = now
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)
endpointStatus, err := scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))
if err != nil {
t.Fatal("shouldn't have returned an error, got", err.Error())
}
if endpointStatus == nil {
t.Fatalf("endpointStatus shouldn't have been nil")
}
if endpointStatus.Name != testEndpoint.Name {
t.Fatalf("endpointStatus.Name should've been %s, got %s", testEndpoint.Name, endpointStatus.Name)
}
if endpointStatus.Group != testEndpoint.Group {
t.Fatalf("endpointStatus.Group should've been %s, got %s", testEndpoint.Group, endpointStatus.Group)
}
if len(endpointStatus.Results) != 2 {
t.Fatalf("endpointStatus.Results should've had 2 entries")
}
if endpointStatus.Results[0].Timestamp.After(endpointStatus.Results[1].Timestamp) {
t.Error("The result at index 0 should've been older than the result at index 1")
}
scenario.Store.InsertEndpointResult(&testEndpoint, &thirdResult)
endpointStatus, err = scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))
if err != nil {
t.Fatal("shouldn't have returned an error, got", err.Error())
}
if len(endpointStatus.Results) != 3 {
t.Fatalf("endpointStatus.Results should've had 3 entries")
}
scenario.Store.Clear()
})
}
}
func TestStore_GetEndpointStatusForMissingStatusReturnsNil(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetEndpointStatusForMissingStatusReturnsNil")
defer cleanUp(scenarios)
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
endpointStatus, err := scenario.Store.GetEndpointStatus("nonexistantgroup", "nonexistantname", paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))
if !errors.Is(err, common.ErrEndpointNotFound) {
t.Error("should've returned ErrEndpointNotFound, got", err)
}
if endpointStatus != nil {
t.Errorf("Returned endpoint status for group '%s' and name '%s' not nil after inserting the endpoint into the store", testEndpoint.Group, testEndpoint.Name)
}
endpointStatus, err = scenario.Store.GetEndpointStatus(testEndpoint.Group, "nonexistantname", paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))
if !errors.Is(err, common.ErrEndpointNotFound) {
t.Error("should've returned ErrEndpointNotFound, got", err)
}
if endpointStatus != nil {
t.Errorf("Returned endpoint status for group '%s' and name '%s' not nil after inserting the endpoint into the store", testEndpoint.Group, "nonexistantname")
}
endpointStatus, err = scenario.Store.GetEndpointStatus("nonexistantgroup", testEndpoint.Name, paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))
if !errors.Is(err, common.ErrEndpointNotFound) {
t.Error("should've returned ErrEndpointNotFound, got", err)
}
if endpointStatus != nil {
t.Errorf("Returned endpoint status for group '%s' and name '%s' not nil after inserting the endpoint into the store", "nonexistantgroup", testEndpoint.Name)
}
})
}
}
func TestStore_GetAllEndpointStatuses(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetAllEndpointStatuses")
defer cleanUp(scenarios)
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)
endpointStatuses, err := scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))
if err != nil {
t.Error("shouldn't have returned an error, got", err.Error())
}
if len(endpointStatuses) != 1 {
t.Fatal("expected 1 endpoint status")
}
actual := endpointStatuses[0]
if actual == nil {
t.Fatal("expected endpoint status to exist")
}
if len(actual.Results) != 2 {
t.Error("expected 2 results, got", len(actual.Results))
}
if len(actual.Events) != 0 {
t.Error("expected 0 events, got", len(actual.Events))
}
scenario.Store.Clear()
})
t.Run(scenario.Name+"-page-2", func(t *testing.T) {
otherEndpoint := testEndpoint
otherEndpoint.Name = testEndpoint.Name + "-other"
scenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)
scenario.Store.InsertEndpointResult(&otherEndpoint, &testSuccessfulResult)
scenario.Store.InsertEndpointResult(&otherEndpoint, &testSuccessfulResult)
scenario.Store.InsertEndpointResult(&otherEndpoint, &testSuccessfulResult)
endpointStatuses, err := scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(2, 2))
if err != nil {
t.Error("shouldn't have returned an error, got", err.Error())
}
if len(endpointStatuses) != 2 {
t.Fatal("expected 2 endpoint statuses")
}
if endpointStatuses[0] == nil || endpointStatuses[1] == nil {
t.Fatal("expected endpoint status to exist")
}
if len(endpointStatuses[0].Results) != 0 {
t.Error("expected 0 results on the first endpoint, got", len(endpointStatuses[0].Results))
}
if len(endpointStatuses[1].Results) != 1 {
t.Error("expected 1 result on the second endpoint, got", len(endpointStatuses[1].Results))
}
if len(endpointStatuses[0].Events) != 0 {
t.Error("expected 0 events on the first endpoint, got", len(endpointStatuses[0].Events))
}
if len(endpointStatuses[1].Events) != 0 {
t.Error("expected 0 events on the second endpoint, got", len(endpointStatuses[1].Events))
}
scenario.Store.Clear()
})
}
}
func TestStore_GetAllEndpointStatusesWithResultsAndEvents(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetAllEndpointStatusesWithResultsAndEvents")
defer cleanUp(scenarios)
firstResult := testSuccessfulResult
secondResult := testUnsuccessfulResult
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
endpointStatuses, err := scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20).WithEvents(1, 50))
if err != nil {
t.Error("shouldn't have returned an error, got", err.Error())
}
if len(endpointStatuses) != 1 {
t.Fatal("expected 1 endpoint status")
}
actual := endpointStatuses[0]
if actual == nil {
t.Fatal("expected endpoint status to exist")
}
if len(actual.Results) != 2 {
t.Error("expected 2 results, got", len(actual.Results))
}
if len(actual.Events) != 3 {
t.Error("expected 3 events, got", len(actual.Events))
}
scenario.Store.Clear()
})
}
}
func TestStore_GetEndpointStatusPage1IsHasMoreRecentResultsThanPage2(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetEndpointStatusPage1IsHasMoreRecentResultsThanPage2")
defer cleanUp(scenarios)
firstResult := testSuccessfulResult
firstResult.Timestamp = now.Add(-time.Minute)
secondResult := testUnsuccessfulResult
secondResult.Timestamp = now
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)
endpointStatusPage1, err := scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, 1))
if err != nil {
t.Error("shouldn't have returned an error, got", err.Error())
}
if endpointStatusPage1 == nil {
t.Fatalf("endpointStatusPage1 shouldn't have been nil")
}
if len(endpointStatusPage1.Results) != 1 {
t.Fatalf("endpointStatusPage1 should've had 1 result")
}
endpointStatusPage2, err := scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(2, 1))
if err != nil {
t.Error("shouldn't have returned an error, got", err.Error())
}
if endpointStatusPage2 == nil {
t.Fatalf("endpointStatusPage2 shouldn't have been nil")
}
if len(endpointStatusPage2.Results) != 1 {
t.Fatalf("endpointStatusPage2 should've had 1 result")
}
// Compare the timestamp of both pages
if !endpointStatusPage1.Results[0].Timestamp.After(endpointStatusPage2.Results[0].Timestamp) {
t.Errorf("The result from the first page should've been more recent than the results from the second page")
}
scenario.Store.Clear()
})
}
}
func TestStore_GetUptimeByKey(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetUptimeByKey")
defer cleanUp(scenarios)
firstResult := testSuccessfulResult
firstResult.Timestamp = now.Add(-time.Minute)
secondResult := testUnsuccessfulResult
secondResult.Timestamp = now
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if _, err := scenario.Store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err != common.ErrEndpointNotFound {
t.Errorf("should've returned not found because there's nothing yet, got %v", err)
}
scenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)
if uptime, _ := scenario.Store.GetUptimeByKey(testEndpoint.Key(), now.Add(-time.Hour), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 1h should've been 0.5, got %f", uptime)
}
if uptime, _ := scenario.Store.GetUptimeByKey(testEndpoint.Key(), now.Add(-time.Hour*24), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 24h should've been 0.5, got %f", uptime)
}
if uptime, _ := scenario.Store.GetUptimeByKey(testEndpoint.Key(), now.Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 7d should've been 0.5, got %f", uptime)
}
if _, err := scenario.Store.GetUptimeByKey(testEndpoint.Key(), now, time.Now().Add(-time.Hour)); err == nil {
t.Error("should've returned an error because the parameter 'from' cannot be older than 'to'")
}
})
}
}
func TestStore_GetAverageResponseTimeByKey(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetAverageResponseTimeByKey")
defer cleanUp(scenarios)
firstResult := testSuccessfulResult
firstResult.Timestamp = now.Add(-(2 * time.Hour))
firstResult.Duration = 300 * time.Millisecond
secondResult := testSuccessfulResult
secondResult.Duration = 150 * time.Millisecond
secondResult.Timestamp = now.Add(-(1*time.Hour + 30*time.Minute))
thirdResult := testUnsuccessfulResult
thirdResult.Duration = 200 * time.Millisecond
thirdResult.Timestamp = now.Add(-(1 * time.Hour))
fourthResult := testSuccessfulResult
fourthResult.Duration = 500 * time.Millisecond
fourthResult.Timestamp = now
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &thirdResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &fourthResult)
if averageResponseTime, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-48*time.Hour), now.Add(-24*time.Hour)); err == nil {
if averageResponseTime != 0 {
t.Errorf("expected average response time to be 0ms, got %v", averageResponseTime)
}
} else {
t.Error("shouldn't have returned an error, got", err)
}
if averageResponseTime, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-24*time.Hour), now); err == nil {
if averageResponseTime != 287 {
t.Errorf("expected average response time to be 287ms, got %v", averageResponseTime)
}
} else {
t.Error("shouldn't have returned an error, got", err)
}
if averageResponseTime, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-time.Hour), now); err == nil {
if averageResponseTime != 350 {
t.Errorf("expected average response time to be 350ms, got %v", averageResponseTime)
}
} else {
t.Error("shouldn't have returned an error, got", err)
}
if averageResponseTime, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-2*time.Hour), now.Add(-time.Hour)); err == nil {
if averageResponseTime != 216 {
t.Errorf("expected average response time to be 216ms, got %v", averageResponseTime)
}
} else {
t.Error("shouldn't have returned an error, got", err)
}
if _, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now, now.Add(-2*time.Hour)); err == nil {
t.Error("expected an error because from > to, got nil")
}
scenario.Store.Clear()
})
}
}
func TestStore_GetHourlyAverageResponseTimeByKey(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetHourlyAverageResponseTimeByKey")
defer cleanUp(scenarios)
firstResult := testSuccessfulResult
firstResult.Timestamp = now.Add(-(2 * time.Hour))
firstResult.Duration = 300 * time.Millisecond
secondResult := testSuccessfulResult
secondResult.Duration = 150 * time.Millisecond
secondResult.Timestamp = now.Add(-(1*time.Hour + 30*time.Minute))
thirdResult := testUnsuccessfulResult
thirdResult.Duration = 200 * time.Millisecond
thirdResult.Timestamp = now.Add(-(1 * time.Hour))
fourthResult := testSuccessfulResult
fourthResult.Duration = 500 * time.Millisecond
fourthResult.Timestamp = now
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &thirdResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &fourthResult)
hourlyAverageResponseTime, err := scenario.Store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-24*time.Hour), now)
if err != nil {
t.Error("shouldn't have returned an error, got", err)
}
if key := now.Truncate(time.Hour).Unix(); hourlyAverageResponseTime[key] != 500 {
t.Errorf("expected average response time to be 500ms at %d, got %v", key, hourlyAverageResponseTime[key])
}
if key := now.Truncate(time.Hour).Add(-time.Hour).Unix(); hourlyAverageResponseTime[key] != 200 {
t.Errorf("expected average response time to be 200ms at %d, got %v", key, hourlyAverageResponseTime[key])
}
if key := now.Truncate(time.Hour).Add(-2 * time.Hour).Unix(); hourlyAverageResponseTime[key] != 225 {
t.Errorf("expected average response time to be 225ms at %d, got %v", key, hourlyAverageResponseTime[key])
}
scenario.Store.Clear()
})
}
}
func TestStore_Insert(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_Insert")
defer cleanUp(scenarios)
firstResult := testSuccessfulResult
firstResult.Timestamp = now.Add(-time.Minute)
secondResult := testUnsuccessfulResult
secondResult.Timestamp = now
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)
scenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)
ss, err := scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))
if err != nil {
t.Error("shouldn't have returned an error, got", err)
}
if ss == nil {
t.Fatalf("Store should've had key '%s', but didn't", testEndpoint.Key())
}
if len(ss.Events) != 3 {
t.Fatalf("Endpoint '%s' should've had 3 events, got %d", ss.Name, len(ss.Events))
}
if len(ss.Results) != 2 {
t.Fatalf("Endpoint '%s' should've had 2 results, got %d", ss.Name, len(ss.Results))
}
for i, expectedResult := range []endpoint.Result{firstResult, secondResult} {
if expectedResult.HTTPStatus != ss.Results[i].HTTPStatus {
t.Errorf("Result at index %d should've had a HTTPStatus of %d, got %d", i, ss.Results[i].HTTPStatus, expectedResult.HTTPStatus)
}
if expectedResult.DNSRCode != ss.Results[i].DNSRCode {
t.Errorf("Result at index %d should've had a DNSRCode of %s, got %s", i, ss.Results[i].DNSRCode, expectedResult.DNSRCode)
}
if expectedResult.Hostname != ss.Results[i].Hostname {
t.Errorf("Result at index %d should've had a Hostname of %s, got %s", i, ss.Results[i].Hostname, expectedResult.Hostname)
}
if expectedResult.IP != ss.Results[i].IP {
t.Errorf("Result at index %d should've had a IP of %s, got %s", i, ss.Results[i].IP, expectedResult.IP)
}
if expectedResult.Connected != ss.Results[i].Connected {
t.Errorf("Result at index %d should've had a Connected value of %t, got %t", i, ss.Results[i].Connected, expectedResult.Connected)
}
if expectedResult.Duration != ss.Results[i].Duration {
t.Errorf("Result at index %d should've had a Duration of %s, got %s", i, ss.Results[i].Duration.String(), expectedResult.Duration.String())
}
if len(expectedResult.Errors) != len(ss.Results[i].Errors) {
t.Errorf("Result at index %d should've had %d errors, but actually had %d errors", i, len(ss.Results[i].Errors), len(expectedResult.Errors))
} else {
for j := range expectedResult.Errors {
if ss.Results[i].Errors[j] != expectedResult.Errors[j] {
t.Error("should've been the same")
}
}
}
if len(expectedResult.ConditionResults) != len(ss.Results[i].ConditionResults) {
t.Errorf("Result at index %d should've had %d ConditionResults, but actually had %d ConditionResults", i, len(ss.Results[i].ConditionResults), len(expectedResult.ConditionResults))
} else {
for j := range expectedResult.ConditionResults {
if ss.Results[i].ConditionResults[j].Condition != expectedResult.ConditionResults[j].Condition {
t.Error("should've been the same")
}
if ss.Results[i].ConditionResults[j].Success != expectedResult.ConditionResults[j].Success {
t.Error("should've been the same")
}
}
}
if expectedResult.Success != ss.Results[i].Success {
t.Errorf("Result at index %d should've had a Success of %t, got %t", i, ss.Results[i].Success, expectedResult.Success)
}
if expectedResult.Timestamp.Unix() != ss.Results[i].Timestamp.Unix() {
t.Errorf("Result at index %d should've had a Timestamp of %d, got %d", i, ss.Results[i].Timestamp.Unix(), expectedResult.Timestamp.Unix())
}
if expectedResult.CertificateExpiration != ss.Results[i].CertificateExpiration {
t.Errorf("Result at index %d should've had a CertificateExpiration of %s, got %s", i, ss.Results[i].CertificateExpiration.String(), expectedResult.CertificateExpiration.String())
}
}
})
}
}
func TestStore_DeleteAllEndpointStatusesNotInKeys(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_DeleteAllEndpointStatusesNotInKeys")
defer cleanUp(scenarios)
firstEndpoint := endpoint.Endpoint{Name: "endpoint-1", Group: "group"}
secondEndpoint := endpoint.Endpoint{Name: "endpoint-2", Group: "group"}
r := &testSuccessfulResult
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.InsertEndpointResult(&firstEndpoint, r)
scenario.Store.InsertEndpointResult(&secondEndpoint, r)
if ss, _ := scenario.Store.GetEndpointStatusByKey(firstEndpoint.Key(), paging.NewEndpointStatusParams()); ss == nil {
t.Fatal("firstEndpoint should exist, got", ss)
}
if ss, _ := scenario.Store.GetEndpointStatusByKey(secondEndpoint.Key(), paging.NewEndpointStatusParams()); ss == nil {
t.Fatal("secondEndpoint should exist, got", ss)
}
scenario.Store.DeleteAllEndpointStatusesNotInKeys([]string{firstEndpoint.Key()})
if ss, _ := scenario.Store.GetEndpointStatusByKey(firstEndpoint.Key(), paging.NewEndpointStatusParams()); ss == nil {
t.Error("secondEndpoint should still exist, got", ss)
}
if ss, _ := scenario.Store.GetEndpointStatusByKey(secondEndpoint.Key(), paging.NewEndpointStatusParams()); ss != nil {
t.Error("firstEndpoint should have been deleted, got", ss)
}
// Delete everything
scenario.Store.DeleteAllEndpointStatusesNotInKeys([]string{})
endpointStatuses, _ := scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())
if len(endpointStatuses) != 0 {
t.Errorf("everything should've been deleted")
}
})
}
}
func TestGet(t *testing.T) {
store := Get()
if store == nil {
t.Error("store should've been automatically initialized")
}
}
func TestInitialize(t *testing.T) {
dir := t.TempDir()
type Scenario struct {
Name string
Cfg *storage.Config
ExpectedErr error
}
scenarios := []Scenario{
{
Name: "nil",
Cfg: nil,
ExpectedErr: nil,
},
{
Name: "blank",
Cfg: &storage.Config{},
ExpectedErr: nil,
},
{
Name: "memory-no-path",
Cfg: &storage.Config{Type: storage.TypeMemory},
ExpectedErr: nil,
},
{
Name: "sqlite-no-path",
Cfg: &storage.Config{Type: storage.TypeSQLite},
ExpectedErr: sql.ErrPathNotSpecified,
},
{
Name: "sqlite-with-path",
Cfg: &storage.Config{Type: storage.TypeSQLite, Path: filepath.Join(dir, "TestInitialize_sqlite-with-path.db")},
ExpectedErr: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := Initialize(scenario.Cfg)
if err != scenario.ExpectedErr {
t.Errorf("expected %v, got %v", scenario.ExpectedErr, err)
}
if err != nil {
return
}
if cancelFunc == nil {
t.Error("cancelFunc shouldn't have been nil")
}
if ctx == nil {
t.Error("ctx shouldn't have been nil")
}
if store == nil {
t.Fatal("provider shouldn't have been nit")
}
store.Close()
// Try to initialize it again
err = Initialize(scenario.Cfg)
if !errors.Is(err, scenario.ExpectedErr) {
t.Errorf("expected %v, got %v", scenario.ExpectedErr, err)
return
}
store.Close()
})
}
}
func TestAutoSave(t *testing.T) {
file := filepath.Join(t.TempDir(), "/TestAutoSave.db")
if err := Initialize(&storage.Config{Path: file}); err != nil {
t.Fatal("shouldn't have returned an error")
}
go autoSave(ctx, store, 3*time.Millisecond)
time.Sleep(15 * time.Millisecond)
cancelFunc()
time.Sleep(50 * time.Millisecond)
}
================================================
FILE: storage/type.go
================================================
package storage
// Type of the store.
type Type string
const (
TypeMemory Type = "memory" // In-memory store
TypeSQLite Type = "sqlite" // SQLite store
TypePostgres Type = "postgres" // Postgres store
)
================================================
FILE: test/mock.go
================================================
package test
import "net/http"
type MockRoundTripper func(r *http.Request) *http.Response
func (f MockRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r), nil
}
================================================
FILE: testdata/badcert.key
================================================
-----BEGIN PRIVATE KEY-----
wat
-----END PRIVATE KEY-----
================================================
FILE: testdata/badcert.pem
================================================
-----BEGIN CERTIFICATE-----
wat
-----END CERTIFICATE-----
================================================
FILE: testdata/cert.key
================================================
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJh67FWpz8wrN1mM/
CebkZN0zF83691ZVD83XlbNLRUqhRANCAAScfyPxScqz+Z/yNtAID/FOORy9J6LM
DUAJevGDvAZCMp/nh+Ps3nLrMoRlykcux3mq+N8HPlJ8R3eetB4S1tHY
-----END PRIVATE KEY-----
================================================
FILE: testdata/cert.pem
================================================
-----BEGIN CERTIFICATE-----
MIIBaDCCAQ2gAwIBAgICBNIwCgYIKoZIzj0EAwIwFTETMBEGA1UEChMKR2F0dXMg
dGVzdDAgFw0yMzA0MjIxODUwMDVaGA8yMjk3MDIwNDE4NTAwNVowFTETMBEGA1UE
ChMKR2F0dXMgdGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJx/I/FJyrP5
n/I20AgP8U45HL0noswNQAl68YO8BkIyn+eH4+zecusyhGXKRy7Hear43wc+UnxH
d560HhLW0dijSzBJMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcD
ATAMBgNVHRMBAf8EAjAAMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDAKBggqhkjOPQQD
AgNJADBGAiEA/SdthKOoNw3azSHuPid7XJsXYB8DisIC9LBwcb/QTMECIQCAB36Y
OI15ao+J/RUz2sXdPXCAN8hlohi6OnmZmJB32g==
-----END CERTIFICATE-----
================================================
FILE: watchdog/alerting.go
================================================
package watchdog
import (
"errors"
"log"
"os"
"time"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/logr"
)
// HandleAlerting takes care of alerts to resolve and alerts to trigger based on result success or failure
func HandleAlerting(ep *endpoint.Endpoint, result *endpoint.Result, alertingConfig *alerting.Config) {
if alertingConfig == nil {
return
}
if result.Success {
handleAlertsToResolve(ep, result, alertingConfig)
} else {
handleAlertsToTrigger(ep, result, alertingConfig)
}
}
func handleAlertsToTrigger(ep *endpoint.Endpoint, result *endpoint.Result, alertingConfig *alerting.Config) {
ep.NumberOfSuccessesInARow = 0
ep.NumberOfFailuresInARow++
// Store the current LastReminderSent time so all alert providers use the same reference time for reminder checks
// This is important in case there are multiple alerts: if the first one sends a reminder, it would update the value
// of ep.LastReminderSent (since ep is a pointer), so the second one would never send a reminder, even if it was due.
// By storing the value in a local variable, we ensure all alerts use the same reference
lastReminderSent := ep.LastReminderSent
for _, endpointAlert := range ep.Alerts {
// If the alert hasn't been triggered, move to the next one
if !endpointAlert.IsEnabled() || endpointAlert.FailureThreshold > ep.NumberOfFailuresInARow {
continue
}
// Determine if an initial alert should be sent
sendInitialAlert := !endpointAlert.Triggered
// Determine if a reminder should be sent
sendReminder := endpointAlert.Triggered && endpointAlert.MinimumReminderInterval > 0 && time.Since(lastReminderSent) >= endpointAlert.MinimumReminderInterval
// If neither initial alert nor reminder needs to be sent, skip to the next alert
if !sendInitialAlert && !sendReminder {
logr.Debugf("[watchdog.handleAlertsToTrigger] Alert for endpoint=%s with description='%s' is not due for triggering or reminding, skipping", ep.Name, endpointAlert.GetDescription())
continue
}
alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type)
if alertProvider != nil {
logr.Infof("[watchdog.handleAlertsToTrigger] Sending %s alert because alert for endpoint with key=%s with description='%s' has been TRIGGERED", endpointAlert.Type, ep.Key(), endpointAlert.GetDescription())
var err error
alertType := "reminder"
if sendInitialAlert {
alertType = "initial"
}
log.Printf("[watchdog.handleAlertsToTrigger] Sending %s %s alert because alert for endpoint=%s with description='%s' has been TRIGGERED", alertType, endpointAlert.Type, ep.Name, endpointAlert.GetDescription())
if os.Getenv("MOCK_ALERT_PROVIDER") == "true" {
if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" {
err = errors.New("error")
}
} else {
err = alertProvider.Send(ep, endpointAlert, result, false)
}
if err != nil {
logr.Errorf("[watchdog.handleAlertsToTrigger] Failed to send an alert for endpoint with key=%s: %s", ep.Key(), err.Error())
} else {
// Mark initial alert as triggered and update last reminder time
if sendInitialAlert {
endpointAlert.Triggered = true
}
ep.LastReminderSent = time.Now()
if err := store.Get().UpsertTriggeredEndpointAlert(ep, endpointAlert); err != nil {
logr.Errorf("[watchdog.handleAlertsToTrigger] Failed to persist triggered endpoint alert for endpoint with key=%s: %s", ep.Key(), err.Error())
}
}
} else {
logr.Warnf("[watchdog.handleAlertsToTrigger] Not sending alert of type=%s endpoint with key=%s despite being TRIGGERED, because the provider wasn't configured properly", endpointAlert.Type, ep.Key())
}
}
}
func handleAlertsToResolve(ep *endpoint.Endpoint, result *endpoint.Result, alertingConfig *alerting.Config) {
ep.NumberOfSuccessesInARow++
for _, endpointAlert := range ep.Alerts {
isStillBelowSuccessThreshold := endpointAlert.SuccessThreshold > ep.NumberOfSuccessesInARow
if isStillBelowSuccessThreshold && endpointAlert.IsEnabled() && endpointAlert.Triggered {
// Persist NumberOfSuccessesInARow
if err := store.Get().UpsertTriggeredEndpointAlert(ep, endpointAlert); err != nil {
logr.Errorf("[watchdog.handleAlertsToResolve] Failed to update triggered endpoint alert for endpoint with key=%s: %s", ep.Key(), err.Error())
}
}
if !endpointAlert.IsEnabled() || !endpointAlert.Triggered || isStillBelowSuccessThreshold {
continue
}
// Even if the alert provider returns an error, we still set the alert's Triggered variable to false.
// Further explanation can be found on Alert's Triggered field.
endpointAlert.Triggered = false
if err := store.Get().DeleteTriggeredEndpointAlert(ep, endpointAlert); err != nil {
logr.Errorf("[watchdog.handleAlertsToResolve] Failed to delete persisted triggered endpoint alert for endpoint with key=%s: %s", ep.Key(), err.Error())
}
if !endpointAlert.IsSendingOnResolved() {
logr.Debugf("[watchdog.handleAlertsToResolve] Not sending request to provider of alert with type=%s for endpoint with key=%s despite being RESOLVED, because send-on-resolved is set to false", endpointAlert.Type, ep.Key())
continue
}
alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type)
if alertProvider != nil {
logr.Infof("[watchdog.handleAlertsToResolve] Sending %s alert because alert for endpoint with key=%s with description='%s' has been RESOLVED", endpointAlert.Type, ep.Key(), endpointAlert.GetDescription())
err := alertProvider.Send(ep, endpointAlert, result, true)
if err != nil {
logr.Errorf("[watchdog.handleAlertsToResolve] Failed to send an alert for endpoint with key=%s: %s", ep.Key(), err.Error())
}
} else {
logr.Warnf("[watchdog.handleAlertsToResolve] Not sending alert of type=%s for endpoint with key=%s despite being RESOLVED, because the provider wasn't configured properly", endpointAlert.Type, ep.Key())
}
}
ep.NumberOfFailuresInARow = 0
}
================================================
FILE: watchdog/alerting_test.go
================================================
package watchdog
import (
"os"
"testing"
"time"
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
"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/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/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
)
func TestHandleAlerting(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health",
Method: "GET",
},
},
},
}
enabled := true
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypeCustom,
Enabled: &enabled,
FailureThreshold: 2,
SuccessThreshold: 3,
SendOnResolved: &enabled,
Triggered: false,
},
},
}
verify(t, ep, 0, 0, false, "The alert shouldn't start triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 1, 0, false, "The alert shouldn't have triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 2, 0, true, "The alert should've triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 3, 0, true, "The alert should still be triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 4, 0, true, "The alert should still be triggered")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 1, true, "The alert should still be triggered (because endpoint.Alerts[0].SuccessThreshold is 3)")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 2, true, "The alert should still be triggered (because endpoint.Alerts[0].SuccessThreshold is 3)")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 3, false, "The alert should've been resolved")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 4, false, "The alert should no longer be triggered")
}
func TestHandleAlertingWhenAlertingConfigIsNil(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
HandleAlerting(nil, nil, nil)
}
func TestHandleAlertingWithBadAlertProvider(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
enabled := true
ep := &endpoint.Endpoint{
URL: "http://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypeCustom,
Enabled: &enabled,
FailureThreshold: 1,
SuccessThreshold: 1,
SendOnResolved: &enabled,
Triggered: false,
},
},
}
verify(t, ep, 0, 0, false, "The alert shouldn't start triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, &alerting.Config{})
verify(t, ep, 1, 0, false, "The alert shouldn't have triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, &alerting.Config{})
verify(t, ep, 2, 0, false, "The alert shouldn't have triggered, because the provider wasn't configured properly")
}
func TestHandleAlertingWhenTriggeredAlertIsAlmostResolvedButendpointStartFailingAgain(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health",
Method: "GET",
},
},
},
}
enabled := true
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypeCustom,
Enabled: &enabled,
FailureThreshold: 2,
SuccessThreshold: 3,
SendOnResolved: &enabled,
Triggered: true,
},
},
NumberOfFailuresInARow: 1,
}
// This test simulate an alert that was already triggered
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 2, 0, true, "The alert was already triggered at the beginning of this test")
}
func TestHandleAlertingWhenTriggeredAlertIsResolvedButSendOnResolvedIsFalse(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health",
Method: "GET",
},
},
},
}
enabled := true
disabled := false
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypeCustom,
Enabled: &enabled,
FailureThreshold: 1,
SuccessThreshold: 1,
SendOnResolved: &disabled,
Triggered: true,
},
},
NumberOfFailuresInARow: 1,
}
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 1, false, "The alert should've been resolved")
}
func TestHandleAlertingWhenTriggeredAlertIsResolvedPagerDuty(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{
PagerDuty: &pagerduty.AlertProvider{
DefaultConfig: pagerduty.Config{
IntegrationKey: "00000000000000000000000000000000",
},
},
},
}
enabled := true
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypePagerDuty,
Enabled: &enabled,
FailureThreshold: 1,
SuccessThreshold: 1,
SendOnResolved: &enabled,
Triggered: false,
},
},
NumberOfFailuresInARow: 0,
}
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 1, 0, true, "")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 1, false, "The alert should've been resolved")
}
func TestHandleAlertingWhenTriggeredAlertIsResolvedPushover(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{
Pushover: &pushover.AlertProvider{
DefaultConfig: pushover.Config{
ApplicationToken: "000000000000000000000000000000",
UserKey: "000000000000000000000000000000",
},
},
},
}
enabled := true
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypePushover,
Enabled: &enabled,
FailureThreshold: 1,
SuccessThreshold: 1,
SendOnResolved: &enabled,
Triggered: false,
},
},
NumberOfFailuresInARow: 0,
}
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 1, 0, true, "")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 1, false, "The alert should've been resolved")
}
func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
enabled := true
scenarios := []struct {
Name string
AlertingConfig *alerting.Config
AlertType alert.Type
}{
{
Name: "custom",
AlertType: alert.TypeCustom,
AlertingConfig: &alerting.Config{
Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health",
Method: "GET",
},
},
},
},
{
Name: "datadog",
AlertType: alert.TypeDatadog,
AlertingConfig: &alerting.Config{
Datadog: &datadog.AlertProvider{
DefaultConfig: datadog.Config{
APIKey: "test-key",
},
},
},
},
{
Name: "discord",
AlertType: alert.TypeDiscord,
AlertingConfig: &alerting.Config{
Discord: &discord.AlertProvider{
DefaultConfig: discord.Config{
WebhookURL: "https://example.com",
},
},
},
},
{
Name: "email",
AlertType: alert.TypeEmail,
AlertingConfig: &alerting.Config{
Email: &email.AlertProvider{
DefaultConfig: email.Config{
From: "from@example.com",
Password: "hunter2",
Host: "mail.example.com",
Port: 587,
To: "to@example.com",
},
},
},
},
{
Name: "ifttt",
AlertType: alert.TypeIFTTT,
AlertingConfig: &alerting.Config{
IFTTT: &ifttt.AlertProvider{
DefaultConfig: ifttt.Config{
WebhookKey: "test-key",
EventName: "test-event",
},
},
},
},
{
Name: "line",
AlertType: alert.TypeLine,
AlertingConfig: &alerting.Config{
Line: &line.AlertProvider{
DefaultConfig: line.Config{
ChannelAccessToken: "test-token",
UserIDs: []string{"test-user"},
},
},
},
},
{
Name: "mattermost",
AlertType: alert.TypeMattermost,
AlertingConfig: &alerting.Config{
Mattermost: &mattermost.AlertProvider{
DefaultConfig: mattermost.Config{
WebhookURL: "https://example.com",
},
},
},
},
{
Name: "messagebird",
AlertType: alert.TypeMessagebird,
AlertingConfig: &alerting.Config{
Messagebird: &messagebird.AlertProvider{
DefaultConfig: messagebird.Config{
AccessKey: "1",
Originator: "2",
Recipients: "3",
},
},
},
},
{
Name: "newrelic",
AlertType: alert.TypeNewRelic,
AlertingConfig: &alerting.Config{
NewRelic: &newrelic.AlertProvider{
DefaultConfig: newrelic.Config{
InsertKey: "test-key",
AccountID: "test-account",
},
},
},
},
{
Name: "pagerduty",
AlertType: alert.TypePagerDuty,
AlertingConfig: &alerting.Config{
PagerDuty: &pagerduty.AlertProvider{
DefaultConfig: pagerduty.Config{
IntegrationKey: "00000000000000000000000000000000",
},
},
},
},
{
Name: "plivo",
AlertType: alert.TypePlivo,
AlertingConfig: &alerting.Config{
Plivo: &plivo.AlertProvider{
DefaultConfig: plivo.Config{
AuthID: "test-id",
AuthToken: "test-token",
From: "test-from",
To: []string{"test-to"},
},
},
},
},
{
Name: "pushover",
AlertType: alert.TypePushover,
AlertingConfig: &alerting.Config{
Pushover: &pushover.AlertProvider{
DefaultConfig: pushover.Config{
ApplicationToken: "000000000000000000000000000000",
UserKey: "000000000000000000000000000000",
},
},
},
},
{
Name: "signl4",
AlertType: alert.TypeSIGNL4,
AlertingConfig: &alerting.Config{
SIGNL4: &signl4.AlertProvider{
DefaultConfig: signl4.Config{
TeamSecret: "test-secret",
},
},
},
},
{
Name: "slack",
AlertType: alert.TypeSlack,
AlertingConfig: &alerting.Config{
Slack: &slack.AlertProvider{
DefaultConfig: slack.Config{
WebhookURL: "https://example.com",
},
},
},
},
{
Name: "teams",
AlertType: alert.TypeTeams,
AlertingConfig: &alerting.Config{
Teams: &teams.AlertProvider{
DefaultConfig: teams.Config{
WebhookURL: "https://example.com",
},
},
},
},
{
Name: "telegram",
AlertType: alert.TypeTelegram,
AlertingConfig: &alerting.Config{
Telegram: &telegram.AlertProvider{
DefaultConfig: telegram.Config{
Token: "1",
ID: "2",
},
},
},
},
{
Name: "twilio",
AlertType: alert.TypeTwilio,
AlertingConfig: &alerting.Config{
Twilio: &twilio.AlertProvider{
DefaultConfig: twilio.Config{
SID: "1",
Token: "2",
From: "3",
To: "4",
},
},
},
},
{
Name: "vonage",
AlertType: alert.TypeVonage,
AlertingConfig: &alerting.Config{
Vonage: &vonage.AlertProvider{
DefaultConfig: vonage.Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "test-from",
To: []string{"test-to"},
},
},
},
},
{
Name: "zapier",
AlertType: alert.TypeZapier,
AlertingConfig: &alerting.Config{
Zapier: &zapier.AlertProvider{
DefaultConfig: zapier.Config{
WebhookURL: "https://example.com",
},
},
},
},
{
Name: "matrix",
AlertType: alert.TypeMatrix,
AlertingConfig: &alerting.Config{
Matrix: &matrix.AlertProvider{
DefaultConfig: matrix.Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
},
},
},
{
Name: "clickup",
AlertType: alert.TypeClickUp,
AlertingConfig: &alerting.Config{
ClickUp: &clickup.AlertProvider{
DefaultConfig: clickup.Config{
ListID: "test-list-id",
Token: "test-token",
},
},
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: scenario.AlertType,
Enabled: &enabled,
FailureThreshold: 2,
SuccessThreshold: 2,
SendOnResolved: &enabled,
Triggered: false,
},
},
}
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true")
HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)
verify(t, ep, 1, 0, false, "")
HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)
verify(t, ep, 2, 0, false, "The alert should have failed to trigger, because the alert provider is returning an error")
HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)
verify(t, ep, 3, 0, false, "The alert should still not be triggered, because the alert provider is still returning an error")
HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)
verify(t, ep, 4, 0, false, "The alert should still not be triggered, because the alert provider is still returning an error")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false")
HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)
verify(t, ep, 5, 0, true, "The alert should've been triggered because the alert provider is no longer returning an error")
HandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig)
verify(t, ep, 0, 1, true, "The alert should've still been triggered")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true")
HandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig)
verify(t, ep, 0, 2, false, "The alert should've been resolved DESPITE THE ALERT PROVIDER RETURNING AN ERROR. See Alert.Triggered for further explanation.")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false")
// Make sure that everything's working as expected after a rough patch
HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)
verify(t, ep, 1, 0, false, "")
HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)
verify(t, ep, 2, 0, true, "The alert should have triggered")
HandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig)
verify(t, ep, 0, 1, true, "The alert should still be triggered")
HandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig)
verify(t, ep, 0, 2, false, "The alert should have been resolved")
})
}
}
func TestHandleAlertingWithProviderThatOnlyReturnsErrorOnResolve(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health",
Method: "GET",
},
},
},
}
enabled := true
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypeCustom,
Enabled: &enabled,
FailureThreshold: 1,
SuccessThreshold: 1,
SendOnResolved: &enabled,
Triggered: false,
},
},
}
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 1, 0, true, "")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 1, false, "")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 1, 0, true, "")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 1, false, "")
_ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false")
// Make sure that everything's working as expected after a rough patch
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 1, 0, true, "")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 2, 0, true, "")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 1, false, "")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
verify(t, ep, 0, 2, false, "")
}
func TestHandleAlertingWithMinimumReminderInterval(t *testing.T) {
_ = os.Setenv("MOCK_ALERT_PROVIDER", "true")
defer os.Clearenv()
cfg := &config.Config{
Alerting: &alerting.Config{
Custom: &custom.AlertProvider{
DefaultConfig: custom.Config{
URL: "https://twin.sh/health",
Method: "GET",
},
},
},
}
enabled := true
ep := &endpoint.Endpoint{
URL: "https://example.com",
Alerts: []*alert.Alert{
{
Type: alert.TypeCustom,
Enabled: &enabled,
FailureThreshold: 2,
SuccessThreshold: 3,
SendOnResolved: &enabled,
Triggered: false,
MinimumReminderInterval: 5 * time.Minute,
},
},
}
verify(t, ep, 0, 0, false, "The alert shouldn't start triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 1, 0, false, "The alert shouldn't have triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 2, 0, true, "The alert should've triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 3, 0, true, "The alert should still be triggered")
HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)
verify(t, ep, 4, 0, true, "The alert should still be triggered")
HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)
}
func verify(t *testing.T, ep *endpoint.Endpoint, expectedNumberOfFailuresInARow, expectedNumberOfSuccessInARow int, expectedTriggered bool, expectedTriggeredReason string) {
if ep.NumberOfFailuresInARow != expectedNumberOfFailuresInARow {
t.Errorf("endpoint.NumberOfFailuresInARow should've been %d, got %d", expectedNumberOfFailuresInARow, ep.NumberOfFailuresInARow)
}
if ep.NumberOfSuccessesInARow != expectedNumberOfSuccessInARow {
t.Errorf("endpoint.NumberOfSuccessesInARow should've been %d, got %d", expectedNumberOfSuccessInARow, ep.NumberOfSuccessesInARow)
}
if ep.Alerts[0].Triggered != expectedTriggered {
if len(expectedTriggeredReason) != 0 {
t.Error(expectedTriggeredReason)
} else {
if expectedTriggered {
t.Error("The alert should've been triggered")
} else {
t.Error("The alert shouldn't have been triggered")
}
}
}
}
================================================
FILE: watchdog/endpoint.go
================================================
package watchdog
import (
"context"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/logr"
)
// monitorEndpoint a single endpoint in a loop
func monitorEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []string, ctx context.Context) {
// Run it immediately on start
executeEndpoint(ep, cfg, extraLabels)
// Loop for the next executions
ticker := time.NewTicker(ep.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logr.Warnf("[watchdog.monitorEndpoint] Canceling current execution of group=%s; endpoint=%s; key=%s", ep.Group, ep.Name, ep.Key())
return
case <-ticker.C:
executeEndpoint(ep, cfg, extraLabels)
}
}
// Just in case somebody wandered all the way to here and wonders, "what about ExternalEndpoints?"
// Alerting is checked every time an external endpoint is pushed to Gatus, so they're not monitored
// periodically like they are for normal endpoints.
}
func executeEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []string) {
// Acquire semaphore to limit concurrent endpoint monitoring
if err := monitoringSemaphore.Acquire(ctx, 1); err != nil {
// Only fails if context is cancelled (during shutdown)
logr.Debugf("[watchdog.executeEndpoint] Context cancelled, skipping execution: %s", err.Error())
return
}
defer monitoringSemaphore.Release(1)
// If there's a connectivity checker configured, check if Gatus has internet connectivity
if cfg.Connectivity != nil && cfg.Connectivity.Checker != nil && !cfg.Connectivity.Checker.IsConnected() {
logr.Infof("[watchdog.executeEndpoint] No connectivity; skipping execution")
return
}
logr.Debugf("[watchdog.executeEndpoint] Monitoring group=%s; endpoint=%s; key=%s", ep.Group, ep.Name, ep.Key())
result := ep.EvaluateHealth()
if cfg.Metrics {
metrics.PublishMetricsForEndpoint(ep, result, extraLabels)
}
UpdateEndpointStatus(ep, result)
if logr.GetThreshold() == logr.LevelDebug && !result.Success {
logr.Debugf("[watchdog.executeEndpoint] Monitored group=%s; endpoint=%s; key=%s; success=%v; errors=%d; duration=%s; body=%s", ep.Group, ep.Name, ep.Key(), result.Success, len(result.Errors), result.Duration.Round(time.Millisecond), result.Body)
} else {
logr.Infof("[watchdog.executeEndpoint] Monitored group=%s; endpoint=%s; key=%s; success=%v; errors=%d; duration=%s", ep.Group, ep.Name, ep.Key(), result.Success, len(result.Errors), result.Duration.Round(time.Millisecond))
}
inEndpointMaintenanceWindow := false
for _, maintenanceWindow := range ep.MaintenanceWindows {
if maintenanceWindow.IsUnderMaintenance() {
logr.Debug("[watchdog.executeEndpoint] Under endpoint maintenance window")
inEndpointMaintenanceWindow = true
}
}
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
HandleAlerting(ep, result, cfg.Alerting)
} else {
logr.Debug("[watchdog.executeEndpoint] Not handling alerting because currently in the maintenance window")
}
logr.Debugf("[watchdog.executeEndpoint] Waiting for interval=%s before monitoring group=%s endpoint=%s (key=%s) again", ep.Interval, ep.Group, ep.Name, ep.Key())
}
// UpdateEndpointStatus persists the endpoint result in the storage
func UpdateEndpointStatus(ep *endpoint.Endpoint, result *endpoint.Result) {
if err := store.Get().InsertEndpointResult(ep, result); err != nil {
logr.Errorf("[watchdog.UpdateEndpointStatus] Failed to insert result in storage: %s", err.Error())
}
}
================================================
FILE: watchdog/external_endpoint.go
================================================
package watchdog
import (
"context"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/logr"
)
func monitorExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, cfg *config.Config, extraLabels []string, ctx context.Context) {
ticker := time.NewTicker(ee.Heartbeat.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logr.Warnf("[watchdog.monitorExternalEndpointHeartbeat] Canceling current execution of group=%s; endpoint=%s; key=%s", ee.Group, ee.Name, ee.Key())
return
case <-ticker.C:
executeExternalEndpointHeartbeat(ee, cfg, extraLabels)
}
}
}
func executeExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, cfg *config.Config, extraLabels []string) {
// Acquire semaphore to limit concurrent external endpoint monitoring
if err := monitoringSemaphore.Acquire(ctx, 1); err != nil {
// Only fails if context is cancelled (during shutdown)
logr.Debugf("[watchdog.executeExternalEndpointHeartbeat] Context cancelled, skipping execution: %s", err.Error())
return
}
defer monitoringSemaphore.Release(1)
// If there's a connectivity checker configured, check if Gatus has internet connectivity
if cfg.Connectivity != nil && cfg.Connectivity.Checker != nil && !cfg.Connectivity.Checker.IsConnected() {
logr.Infof("[watchdog.monitorExternalEndpointHeartbeat] No connectivity; skipping execution")
return
}
logr.Debugf("[watchdog.monitorExternalEndpointHeartbeat] Checking heartbeat for group=%s; endpoint=%s; key=%s", ee.Group, ee.Name, ee.Key())
convertedEndpoint := ee.ToEndpoint()
hasReceivedResultWithinHeartbeatInterval, err := store.Get().HasEndpointStatusNewerThan(ee.Key(), time.Now().Add(-ee.Heartbeat.Interval))
if err != nil {
logr.Errorf("[watchdog.monitorExternalEndpointHeartbeat] Failed to check if endpoint has received a result within the heartbeat interval: %s", err.Error())
return
}
if hasReceivedResultWithinHeartbeatInterval {
// If we received a result within the heartbeat interval, we don't want to create a successful result, so we
// skip the rest. We don't have to worry about alerting or metrics, because if the previous heartbeat failed
// while this one succeeds, it implies that there was a new result pushed, and that result being pushed
// should've resolved the alert.
logr.Infof("[watchdog.monitorExternalEndpointHeartbeat] Checked heartbeat for group=%s; endpoint=%s; key=%s; success=%v; errors=%d", ee.Group, ee.Name, ee.Key(), hasReceivedResultWithinHeartbeatInterval, 0)
return
}
// All code after this point assumes the heartbeat failed
result := &endpoint.Result{
Timestamp: time.Now(),
Success: false,
Errors: []string{"heartbeat: no update received within " + ee.Heartbeat.Interval.String()},
}
if cfg.Metrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
}
UpdateEndpointStatus(convertedEndpoint, result)
logr.Infof("[watchdog.monitorExternalEndpointHeartbeat] Checked heartbeat for group=%s; endpoint=%s; key=%s; success=%v; errors=%d; duration=%s", ee.Group, ee.Name, ee.Key(), result.Success, len(result.Errors), result.Duration.Round(time.Millisecond))
inEndpointMaintenanceWindow := false
for _, maintenanceWindow := range ee.MaintenanceWindows {
if maintenanceWindow.IsUnderMaintenance() {
logr.Debug("[watchdog.monitorExternalEndpointHeartbeat] Under endpoint maintenance window")
inEndpointMaintenanceWindow = true
}
}
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
HandleAlerting(convertedEndpoint, result, cfg.Alerting)
// Sync the failure/success counters back to the external endpoint
ee.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
ee.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
} else {
logr.Debug("[watchdog.monitorExternalEndpointHeartbeat] Not handling alerting because currently in the maintenance window")
}
logr.Debugf("[watchdog.monitorExternalEndpointHeartbeat] Waiting for interval=%s before checking heartbeat for group=%s endpoint=%s (key=%s) again", ee.Heartbeat.Interval, ee.Group, ee.Name, ee.Key())
}
================================================
FILE: watchdog/suite.go
================================================
package watchdog
import (
"context"
"time"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/logr"
)
// monitorSuite monitors a suite by executing it at regular intervals
func monitorSuite(s *suite.Suite, cfg *config.Config, extraLabels []string, ctx context.Context) {
// Execute immediately on start
executeSuite(s, cfg, extraLabels)
// Set up ticker for periodic execution
ticker := time.NewTicker(s.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logr.Warnf("[watchdog.monitorSuite] Canceling monitoring for suite=%s", s.Name)
return
case <-ticker.C:
executeSuite(s, cfg, extraLabels)
}
}
}
// executeSuite executes a suite with proper concurrency control
func executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) {
// Acquire semaphore to limit concurrent suite monitoring
if err := monitoringSemaphore.Acquire(ctx, 1); err != nil {
// Only fails if context is cancelled (during shutdown)
logr.Debugf("[watchdog.executeSuite] Context cancelled, skipping execution: %s", err.Error())
return
}
defer monitoringSemaphore.Release(1)
// Check connectivity if configured
if cfg.Connectivity != nil && cfg.Connectivity.Checker != nil && !cfg.Connectivity.Checker.IsConnected() {
logr.Infof("[watchdog.executeSuite] No connectivity; skipping suite=%s", s.Name)
return
}
logr.Debugf("[watchdog.executeSuite] Monitoring group=%s; suite=%s; key=%s", s.Group, s.Name, s.Key())
// Execute the suite using its Execute method
result := s.Execute()
// Publish metrics for the suite execution
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) {
epResult := result.EndpointResults[i]
// Handle alerting if configured and not under maintenance
if cfg.Alerting != nil && !cfg.Maintenance.IsUnderMaintenance() {
// Check if endpoint is under maintenance
inEndpointMaintenanceWindow := false
for _, maintenanceWindow := range ep.MaintenanceWindows {
if maintenanceWindow.IsUnderMaintenance() {
logr.Debug("[watchdog.executeSuite] Endpoint under maintenance window")
inEndpointMaintenanceWindow = true
break
}
}
if !inEndpointMaintenanceWindow {
HandleAlerting(ep, epResult, cfg.Alerting)
}
}
}
}
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))
}
// UpdateSuiteStatus persists the suite result in the database
func UpdateSuiteStatus(s *suite.Suite, result *suite.Result) {
if err := store.Get().InsertSuiteResult(s, result); err != nil {
logr.Errorf("[watchdog.executeSuite] Failed to insert suite result for suite=%s: %v", s.Name, err)
}
}
================================================
FILE: watchdog/watchdog.go
================================================
package watchdog
import (
"context"
"time"
"github.com/TwiN/gatus/v5/config"
"golang.org/x/sync/semaphore"
)
const (
// UnlimitedConcurrencyWeight is the semaphore weight used when concurrency is set to 0 (unlimited).
// This provides a practical upper limit while allowing very high concurrency for large deployments.
UnlimitedConcurrencyWeight = 10000
)
var (
// monitoringSemaphore is used to limit the number of endpoints/suites that can be evaluated concurrently.
// Without this, conditions using response time may become inaccurate.
monitoringSemaphore *semaphore.Weighted
ctx context.Context
cancelFunc context.CancelFunc
)
// Monitor loops over each endpoint and starts a goroutine to monitor each endpoint separately
func Monitor(cfg *config.Config) {
ctx, cancelFunc = context.WithCancel(context.Background())
// Initialize semaphore based on concurrency configuration
if cfg.Concurrency == 0 {
// Unlimited concurrency - use a very high limit
monitoringSemaphore = semaphore.NewWeighted(UnlimitedConcurrencyWeight)
} else {
// Limited concurrency based on configuration
monitoringSemaphore = semaphore.NewWeighted(int64(cfg.Concurrency))
}
extraLabels := cfg.GetUniqueExtraMetricLabels()
for _, endpoint := range cfg.Endpoints {
if endpoint.IsEnabled() {
// To prevent multiple requests from running at the same time, we'll wait for a little before each iteration
time.Sleep(222 * time.Millisecond)
go monitorEndpoint(endpoint, cfg, extraLabels, ctx)
}
}
for _, externalEndpoint := range cfg.ExternalEndpoints {
// Check if the external endpoint is enabled and is using heartbeat
// If the external endpoint does not use heartbeat, then it does not need to be monitored periodically, because
// alerting is checked every time an external endpoint is pushed to Gatus, unlike normal endpoints.
if externalEndpoint.IsEnabled() && externalEndpoint.Heartbeat.Interval > 0 {
go monitorExternalEndpointHeartbeat(externalEndpoint, cfg, extraLabels, ctx)
}
}
for _, suite := range cfg.Suites {
if suite.IsEnabled() {
time.Sleep(222 * time.Millisecond)
go monitorSuite(suite, cfg, extraLabels, ctx)
}
}
}
// Shutdown stops monitoring all endpoints
func Shutdown(cfg *config.Config) {
// Stop in-flight HTTP connections
for _, ep := range cfg.Endpoints {
ep.Close()
}
for _, s := range cfg.Suites {
for _, ep := range s.Endpoints {
ep.Close()
}
}
cancelFunc()
}
================================================
FILE: web/app/.gitignore
================================================
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
================================================
FILE: web/app/README.md
================================================
# app
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
================================================
FILE: web/app/babel.config.js
================================================
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
================================================
FILE: web/app/package.json
================================================
{
"name": "gatus",
"version": "4.0.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --mode development",
"build": "vue-cli-service build --modern --mode production",
"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",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.28.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.1.8"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser",
"requireConfigFile": false
},
"rules": {
"vue/multi-word-component-names": ["error", {
"ignores": ["Home", "Details", "Loading", "Settings", "Social", "Tooltip", "Pagination", "Button", "Badge", "Card", "Input", "Select"]
}]
},
"globals": {
"defineProps": "readonly",
"defineEmits": "readonly",
"defineExpose": "readonly",
"withDefaults": "readonly"
}
},
"browserslist": [
"defaults",
"> 1%",
"last 2 versions",
"not dead"
]
}
================================================
FILE: web/app/postcss.config.js
================================================
const tailwindcss = require('tailwindcss');
module.exports = {
plugins: [
tailwindcss('./tailwind.config.js'),
require('autoprefixer'),
],
};
================================================
FILE: web/app/public/index.html
================================================
{{ .UI.Title }}
Enable JavaScript to view this page.
================================================
FILE: web/app/public/manifest.json
================================================
{
"id": "gatus",
"name": "Gatus",
"short_name": "Gatus",
"description": "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue",
"lang": "en",
"scope": "/",
"start_url": "/",
"theme_color": "#f7f9fb",
"background_color": "#f7f9fb",
"display": "standalone",
"icons": [
{
"src": "/logo-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/logo-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
================================================
FILE: web/app/src/App.vue
================================================
Gatus
System Monitoring Dashboard
You do not have access to this status page
{{ route.query.error }}
Login with OIDC
================================================
FILE: web/app/src/components/AnnouncementBanner.vue
================================================
Announcements
({{ announcements.length }})
{{ formatTime(announcement.timestamp) }}
================================================
FILE: web/app/src/components/EndpointCard.vue
================================================
{{ formattedResponseTime }}
{{ oldestResultTime }}
{{ newestResultTime }}
================================================
FILE: web/app/src/components/FlowStep.vue
================================================
{{ step.name }}
{{ formatDuration(step.duration) }}
Always Run
{{ step.errors.length }} error{{ step.errors.length !== 1 ? 's' : '' }}
================================================
FILE: web/app/src/components/Loading.vue
================================================
================================================
FILE: web/app/src/components/Pagination.vue
================================================
Previous
Page {{ currentPage }} of {{ maxPages }}
Next
================================================
FILE: web/app/src/components/PastAnnouncements.vue
================================================
Past Announcements
{{ formatDate(date) }}
{{ formatTime(announcement.timestamp) }}
No incidents reported on this day
View older announcements
================================================
FILE: web/app/src/components/ResponseTimeChart.vue
================================================
================================================
FILE: web/app/src/components/SearchBar.vue
================================================
================================================
FILE: web/app/src/components/SequentialFlowDiagram.vue
================================================
{{ completedSteps }}/{{ totalSteps }} steps successful
{{ formatDuration(totalDuration) }} total
================================================
FILE: web/app/src/components/Settings.vue
================================================
{{ formatRefreshInterval(refreshIntervalValue) }}
{{ interval.label }}
{{ darkMode ? 'Light mode' : 'Dark mode' }}
================================================
FILE: web/app/src/components/Social.vue
================================================
================================================
FILE: web/app/src/components/StatusBadge.vue
================================================
{{ label }}
================================================
FILE: web/app/src/components/StepDetailsModal.vue
================================================
{{ step.name }}
Step {{ index + 1 }} • {{ formatDuration(step.duration) }}
Always Run
This endpoint is configured to execute even after failures
Errors ({{ step.errors.length }})
Timestamp
{{ prettifyTimestamp(step.result.timestamp) }}
Response
Duration:
{{ formatDuration(step.result.duration) }}
Success:
{{ step.result.success ? 'Yes' : 'No' }}
Condition Results ({{ step.result.conditionResults.length }})
{{ conditionResult.condition }}
{{ conditionResult.success ? 'Passed' : 'Failed' }}
Endpoint Configuration
URL:
{{ step.endpoint.url }}
Method:
{{ step.endpoint.method }}
Interval:
{{ step.endpoint.interval }}
Timeout:
{{ step.endpoint.timeout }}
Result Errors ({{ step.result.errors.length }})
================================================
FILE: web/app/src/components/SuiteCard.vue
================================================
Success Rate: {{ successRate }}%
{{ averageDuration }}ms avg
{{ oldestResultTime }}
{{ newestResultTime }}
================================================
FILE: web/app/src/components/Tooltip.vue
================================================
================================================
FILE: web/app/src/components/ui/badge/Badge.vue
================================================
================================================
FILE: web/app/src/components/ui/badge/index.js
================================================
export { default as Badge } from './Badge.vue'
================================================
FILE: web/app/src/components/ui/button/Button.vue
================================================
================================================
FILE: web/app/src/components/ui/button/index.js
================================================
export { default as Button } from './Button.vue'
================================================
FILE: web/app/src/components/ui/card/Card.vue
================================================
================================================
FILE: web/app/src/components/ui/card/CardContent.vue
================================================
================================================
FILE: web/app/src/components/ui/card/CardHeader.vue
================================================
================================================
FILE: web/app/src/components/ui/card/CardTitle.vue
================================================
================================================
FILE: web/app/src/components/ui/card/index.js
================================================
export { default as Card } from './Card.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as CardContent } from './CardContent.vue'
================================================
FILE: web/app/src/components/ui/input/Input.vue
================================================
================================================
FILE: web/app/src/components/ui/input/index.js
================================================
export { default as Input } from './Input.vue'
================================================
FILE: web/app/src/components/ui/select/Select.vue
================================================
{{ selectedOption.label }}
================================================
FILE: web/app/src/components/ui/select/index.js
================================================
export { default as Select } from './Select.vue'
================================================
FILE: web/app/src/index.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
:root.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.bg-success {
background-color: #28a745;
}
html {
height: 100%;
}
body {
min-height: 100vh;
}
@media screen and (max-width: 1279px) {
body {
padding-top: 0;
padding-bottom: 0;
}
}
================================================
FILE: web/app/src/main.js
================================================
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
import router from './router'
createApp(App).use(router).mount('#app')
================================================
FILE: web/app/src/router/index.js
================================================
import {createRouter, createWebHistory} from 'vue-router'
import Home from '@/views/Home'
import EndpointDetails from "@/views/EndpointDetails";
import SuiteDetails from '@/views/SuiteDetails';
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/endpoints/:key',
name: 'EndpointDetails',
component: EndpointDetails,
},
{
path: '/suites/:key',
name: 'SuiteDetails',
component: SuiteDetails
}
];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
});
export default router;
================================================
FILE: web/app/src/utils/format.js
================================================
/**
* Formats a duration from nanoseconds to a human-readable string
* @param {number} duration - Duration in nanoseconds
* @returns {string} Formatted duration string (e.g., "123ms", "1.23s")
*/
export const formatDuration = (duration) => {
if (!duration && duration !== 0) return 'N/A'
// Convert nanoseconds to milliseconds
const durationMs = duration / 1000000
if (durationMs < 1000) {
return `${Math.trunc(durationMs)}ms`
} else {
return `${(durationMs / 1000).toFixed(2)}s`
}
}
================================================
FILE: web/app/src/utils/markdown.js
================================================
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const escapeHtml = (value) => {
if (value === null || value === undefined) {
return ''
}
return String(value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
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 `${linkText} `
}
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'] })
}
================================================
FILE: web/app/src/utils/misc.js
================================================
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function combineClasses(...inputs) {
return twMerge(clsx(inputs))
}
================================================
FILE: web/app/src/utils/time.js
================================================
/**
* Generates a human-readable relative time string (e.g., "2 hours ago")
* @param {string|Date} timestamp - The timestamp to convert
* @returns {string} Relative time string
*/
export const generatePrettyTimeAgo = (timestamp) => {
let differenceInMs = new Date().getTime() - new Date(timestamp).getTime();
if (differenceInMs < 500) {
return "now";
}
if (differenceInMs > 3 * 86400000) { // If it was more than 3 days ago, we'll display the number of days ago
let days = (differenceInMs / 86400000).toFixed(0);
return days + " day" + (days !== "1" ? "s" : "") + " ago";
}
if (differenceInMs > 3600000) { // If it was more than 1h ago, display the number of hours ago
let hours = (differenceInMs / 3600000).toFixed(0);
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
}
if (differenceInMs > 60000) {
let minutes = (differenceInMs / 60000).toFixed(0);
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
}
let seconds = (differenceInMs / 1000).toFixed(0);
return seconds + " second" + (seconds !== "1" ? "s" : "") + " ago";
}
/**
* Generates a pretty time difference string between two timestamps
* @param {string|Date} start - Start timestamp
* @param {string|Date} end - End timestamp
* @returns {string} Time difference string
*/
export const generatePrettyTimeDifference = (start, end) => {
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')
}
}
/**
* Formats a timestamp into YYYY-MM-DD HH:mm:ss format
* @param {string|Date} timestamp - The timestamp to format
* @returns {string} Formatted timestamp
*/
export const prettifyTimestamp = (timestamp) => {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
let MM = ((date.getMonth() + 1) < 10 ? "0" : "") + "" + (date.getMonth() + 1);
let DD = ((date.getDate()) < 10 ? "0" : "") + "" + (date.getDate());
let hh = ((date.getHours()) < 10 ? "0" : "") + "" + (date.getHours());
let mm = ((date.getMinutes()) < 10 ? "0" : "") + "" + (date.getMinutes());
let ss = ((date.getSeconds()) < 10 ? "0" : "") + "" + (date.getSeconds());
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
}
================================================
FILE: web/app/src/views/EndpointDetails.vue
================================================
Back to Dashboard
{{ endpointStatus.name }}
Group: {{ endpointStatus.group }}
•
{{ hostname }}
Current Status
{{ currentHealthStatus === 'healthy' ? 'Operational' : 'Issues Detected' }}
Avg Response Time
{{ pageAverageResponseTime }}
Response Time Range
{{ pageResponseTimeRange }}
Last Check
{{ lastCheckTime }}
Response Time Trend
24 hours
7 days
30 days
{{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}
Uptime Statistics
{{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}
Current Health
Events
{{ event.fancyText }}
{{ prettifyTimestamp(event.timestamp) }} • {{ event.fancyTimeAgo }}
================================================
FILE: web/app/src/views/Home.vue
================================================
{{ dashboardHeading }}
{{ dashboardSubheading }}
No endpoints or suites found
{{ searchQuery || showOnlyFailing || showRecentFailures
? 'Try adjusting your filters'
: 'No endpoints or suites are configured' }}
================================================
FILE: web/app/src/views/SuiteDetails.vue
================================================
Back to Dashboard
{{ suite?.name || 'Loading...' }}
{{ suite.group }} •
{{ selectedResult && selectedResult.timestamp !== sortedResults[0]?.timestamp ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}
Suite not found
The requested suite could not be found.
{{ selectedResult?.timestamp === sortedResults[0]?.timestamp ? 'Latest Execution' : `Execution at ${formatTimestamp(selectedResult.timestamp)}` }}
Status
{{ latestResult.success ? 'Success' : 'Failed' }}
Duration
{{ formatDuration(latestResult.duration) }}
Endpoints
{{ latestResult.endpointResults?.length || 0 }}
Success Rate
{{ calculateSuccessRate(latestResult) }}%
Execution Flow
Execution History
{{ formatTimestamp(result.timestamp) }}
{{ result.endpointResults?.length || 0 }} endpoints • {{ formatDuration(result.duration) }}
No execution history available
================================================
FILE: web/app/tailwind.config.js
================================================
module.exports = {
content: [
'./public/index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}'
],
darkMode: 'class', // or 'media' or 'class'
theme: {
fontFamily: {
'mono': ['Consolas', 'Monaco', '"Courier New"', 'monospace'],
'sans': ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif']
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
"accordion-down": {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
"accordion-up": {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
variants: {
extend: {},
},
plugins: [],
future: {
hoverOnlyWhenSupported: true,
},
}
================================================
FILE: web/app/vue.config.js
================================================
// Note: The fs.Stats deprecation warning is from Vue CLI's webpack dependencies
// which are not yet compatible with Node.js v23. This is suppressed in the build
// script. All user dependencies have been updated to their latest versions.
// Consider migrating to Vite for better Node.js v23+ compatibility.
module.exports = {
filenameHashing: false,
productionSourceMap: false,
outputDir: '../static',
publicPath: '/',
devServer: {
port: 8081,
https: false,
client: {
webSocketURL:'auto://0.0.0.0/ws'
},
proxy: {
'^/api|^/css|^/oicd': {
target: "http://localhost:8080",
changeOrigin: true,
secure: false,
}
}
}
}
================================================
FILE: web/static/css/app.css
================================================
#social[data-v-788af9ce]{position:fixed;right:5px;bottom:5px;padding:5px;margin:0;z-index:100}#social img[data-v-788af9ce]{opacity:.3}#social img[data-v-788af9ce]:hover{opacity:1}
/*
! tailwindcss v3.1.8 | MIT License | https://tailwindcss.com
*/*,:after,:before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Consolas,Monaco,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}:root{--background:0 0% 100%;--foreground:222.2 84% 4.9%;--card:0 0% 100%;--card-foreground:222.2 84% 4.9%;--popover:0 0% 100%;--popover-foreground:222.2 84% 4.9%;--primary:222.2 47.4% 11.2%;--primary-foreground:210 40% 98%;--secondary:210 40% 96.1%;--secondary-foreground:222.2 47.4% 11.2%;--muted:210 40% 96.1%;--muted-foreground:215.4 16.3% 46.9%;--accent:210 40% 96.1%;--accent-foreground:222.2 47.4% 11.2%;--destructive:0 84.2% 60.2%;--destructive-foreground:210 40% 98%;--border:214.3 31.8% 91.4%;--input:214.3 31.8% 91.4%;--ring:222.2 84% 4.9%;--radius:0.5rem}:root.dark{--background:222.2 84% 4.9%;--foreground:210 40% 98%;--card:222.2 84% 4.9%;--card-foreground:210 40% 98%;--popover:222.2 84% 4.9%;--popover-foreground:210 40% 98%;--primary:210 40% 98%;--primary-foreground:222.2 47.4% 11.2%;--secondary:217.2 32.6% 17.5%;--secondary-foreground:210 40% 98%;--muted:217.2 32.6% 17.5%;--muted-foreground:215 20.2% 65.1%;--accent:217.2 32.6% 17.5%;--accent-foreground:210 40% 98%;--destructive:0 62.8% 30.6%;--destructive-foreground:210 40% 98%;--border:217.2 32.6% 17.5%;--input:217.2 32.6% 17.5%;--ring:212.7 26.8% 83.9%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.-left-\[26px\]{left:-26px}.top-3{top:.75rem}.top-1\/2{top:50%}.left-1\/2{left:50%}.bottom-8{bottom:2rem}.top-8{top:2rem}.left-3{left:.75rem}.bottom-4{bottom:1rem}.left-4{left:1rem}.bottom-full{bottom:100%}.left-0{left:0}.top-full{top:100%}.left-1\.5{left:.375rem}.left-1{left:.25rem}.z-10{z-index:10}.z-50{z-index:50}.-m-2{margin:-.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mt-4{margin-top:1rem}.mt-auto{margin-top:auto}.mb-4{margin-bottom:1rem}.mt-2{margin-top:.5rem}.mb-6{margin-bottom:1.5rem}.mr-2{margin-right:.5rem}.mb-2{margin-bottom:.5rem}.ml-7{margin-left:1.75rem}.ml-2{margin-left:.5rem}.mb-1{margin-bottom:.25rem}.mt-1{margin-top:.25rem}.mb-3{margin-bottom:.75rem}.mt-0\.5{margin-top:.125rem}.mt-0{margin-top:0}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-12{margin-top:3rem}.ml-1{margin-left:.25rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.\!hidden{display:none!important}.h-12{height:3rem}.h-full{height:100%}.h-5{height:1.25rem}.h-20{height:5rem}.h-11{height:2.75rem}.h-4{height:1rem}.h-8{height:2rem}.h-3{height:.75rem}.h-6{height:1.5rem}.h-16{height:4rem}.h-1{height:.25rem}.h-3\.5{height:.875rem}.h-2{height:.5rem}.h-10{height:2.5rem}.h-9{height:2.25rem}.max-h-\[80vh\]{max-height:80vh}.max-h-\[60vh\]{max-height:60vh}.max-h-48{max-height:12rem}.max-h-32{max-height:8rem}.min-h-screen{min-height:100vh}.min-h-\[1\.25rem\]{min-height:1.25rem}.w-12{width:3rem}.w-full{width:100%}.w-5{width:1.25rem}.w-20{width:5rem}.w-4{width:1rem}.w-3{width:.75rem}.w-0\.5{width:.125rem}.w-0{width:0}.w-8{width:2rem}.w-6{width:1.5rem}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-px{width:1px}.w-2{width:.5rem}.w-10{width:2.5rem}.min-w-0{min-width:0}.max-w-7xl{max-width:80rem}.max-w-md{max-width:28rem}.max-w-2xl{max-width:42rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-x-px,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-px{--tw-translate-x:-1px}.-translate-x-1\/2{--tw-translate-x:-50%}.-rotate-90,.-translate-x-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-90{--tw-rotate:-90deg}.rotate-0{--tw-rotate:0deg}.rotate-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.cursor-default{cursor:default}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-4{gap:1rem}.gap-3{gap:.75rem}.gap-2{gap:.5rem}.gap-1{gap:.25rem}.gap-0\.5{gap:.125rem}.gap-0{gap:0}.gap-1\.5{gap:.375rem}.gap-6{gap:1.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-lg{border-radius:var(--radius)}.rounded-full{border-radius:9999px}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded{border-radius:.25rem}.rounded-t-lg{border-top-left-radius:var(--radius);border-top-right-radius:var(--radius)}.rounded-b-lg{border-bottom-right-radius:var(--radius);border-bottom-left-radius:var(--radius)}.border{border-width:1px}.border-2{border-width:2px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-l-4{border-left-width:4px}.border-l-2{border-left-width:2px}.border-dashed{border-style:dashed}.border-destructive\/20{border-color:hsl(var(--destructive)/.2)}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity))}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity))}.border-green-600{--tw-border-opacity:1;border-color:rgb(22 163 74/var(--tw-border-opacity))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity))}.border-blue-600{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity))}.border-border{border-color:hsl(var(--border))}.border-transparent{border-color:transparent}.border-input{border-color:hsl(var(--input))}.bg-background{background-color:hsl(var(--background))}.bg-card\/50{background-color:hsl(var(--card)/.5)}.bg-destructive\/10{background-color:hsl(var(--destructive)/.1)}.bg-primary{background-color:hsl(var(--primary))}.bg-card{background-color:hsl(var(--card))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-green-700{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.bg-red-700{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity))}.bg-transparent{background-color:transparent}.bg-background\/50{background-color:hsl(var(--background)/.5)}.bg-background\/95{background-color:hsl(var(--background)/.95)}.bg-popover{background-color:hsl(var(--popover))}.bg-accent{background-color:hsl(var(--accent))}.bg-border\/50{background-color:hsl(var(--border)/.5)}.bg-green-400{--tw-bg-opacity:1;background-color:rgb(74 222 128/var(--tw-bg-opacity))}.bg-red-400{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity))}.bg-yellow-400{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity))}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity))}.object-contain{-o-object-fit:contain;object-fit:contain}.p-4{padding:1rem}.p-3{padding:.75rem}.p-2{padding:.5rem}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-0{padding:0}.p-6{padding:1.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-4{padding-top:1rem;padding-bottom:1rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-8{padding-top:2rem;padding-bottom:2rem}.py-20{padding-top:5rem;padding-bottom:5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-0{padding-top:0;padding-bottom:0}.pt-4{padding-top:1rem}.pt-3{padding-top:.75rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pt-2{padding-top:.5rem}.pt-1{padding-top:.25rem}.pl-10{padding-left:2.5rem}.pb-4{padding-bottom:1rem}.pb-8{padding-bottom:2rem}.pt-0{padding-top:0}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:Consolas,Monaco,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-base{font-size:1rem;line-height:1.5rem}.text-xs{font-size:.75rem;line-height:1rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-relaxed{line-height:1.625}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity))}.text-destructive{color:hsl(var(--destructive))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-card-foreground{color:hsl(var(--card-foreground))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.text-muted-foreground\/60{color:hsl(var(--muted-foreground)/.6)}.text-popover-foreground{color:hsl(var(--popover-foreground))}.text-blue-900{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-primary{color:hsl(var(--primary))}.text-accent-foreground{color:hsl(var(--accent-foreground))}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.opacity-60{opacity:.6}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-50{opacity:.5}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-none{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-2{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-blue-200{--tw-ring-opacity:1;--tw-ring-color:rgb(191 219 254/var(--tw-ring-opacity))}.ring-offset-background{--tw-ring-offset-color:hsl(var(--background))}.grayscale{--tw-grayscale:grayscale(100%)}.filter,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-blur-sm{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-filter{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.bg-success{background-color:#28a745}html{height:100%}body{min-height:100vh}@media screen and (max-width:1279px){body{padding-top:0;padding-bottom:0}}.file\:border-0::file-selector-button{border-width:0}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.last\:border-0:last-child{border-width:0}@media (hover:hover) and (pointer:fine){.hover\:scale-\[1\.01\]:hover{--tw-scale-x:1.01;--tw-scale-y:1.01}.hover\:scale-110:hover,.hover\:scale-\[1\.01\]:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary)/.9)}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity))}.hover\:bg-accent\/30:hover{background-color:hsl(var(--accent)/.3)}.hover\:bg-accent\/50:hover{background-color:hsl(var(--accent)/.5)}.hover\:bg-primary\/80:hover{background-color:hsl(var(--primary)/.8)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary)/.8)}.hover\:bg-destructive\/80:hover{background-color:hsl(var(--destructive)/.8)}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive)/.9)}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-emerald-600:hover{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity))}.hover\:text-primary:hover{color:hsl(var(--primary))}.hover\:text-blue-700:hover{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-80:hover{opacity:.8}.hover\:shadow-sm:hover{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.hover\:shadow-lg:hover,.hover\:shadow-sm:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-ring:focus{--tw-ring-color:hsl(var(--ring))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (hover:hover) and (pointer:fine){.group:hover .group-hover\:translate-y-0\.5{--tw-translate-y:0.125rem}.group:hover .group-hover\:translate-y-0,.group:hover .group-hover\:translate-y-0\.5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:translate-y-0{--tw-translate-y:0px}.group:hover .group-hover\:underline{text-decoration-line:underline}.group:hover .group-hover\:opacity-100{opacity:1}}.dark .dark\:border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity))}.dark .dark\:border-red-400{--tw-border-opacity:1;border-color:rgb(248 113 113/var(--tw-border-opacity))}.dark .dark\:border-yellow-400{--tw-border-opacity:1;border-color:rgb(250 204 21/var(--tw-border-opacity))}.dark .dark\:border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity))}.dark .dark\:border-green-400{--tw-border-opacity:1;border-color:rgb(74 222 128/var(--tw-border-opacity))}.dark .dark\:border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity))}.dark .dark\:border-blue-800{--tw-border-opacity:1;border-color:rgb(30 64 175/var(--tw-border-opacity))}.dark .dark\:border-blue-700{--tw-border-opacity:1;border-color:rgb(29 78 216/var(--tw-border-opacity))}.dark .dark\:border-red-700{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity))}.dark .dark\:border-green-700{--tw-border-opacity:1;border-color:rgb(21 128 61/var(--tw-border-opacity))}.dark .dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark .dark\:bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.dark .dark\:bg-red-900\/50{background-color:rgba(127,29,29,.5)}.dark .dark\:bg-yellow-900\/50{background-color:rgba(113,63,18,.5)}.dark .dark\:bg-blue-900\/50{background-color:rgba(30,58,138,.5)}.dark .dark\:bg-green-900\/50{background-color:rgba(20,83,45,.5)}.dark .dark\:bg-gray-800\/50{background-color:rgba(31,41,55,.5)}.dark .dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark .dark\:bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity))}.dark .dark\:bg-red-900{--tw-bg-opacity:1;background-color:rgb(127 29 29/var(--tw-bg-opacity))}.dark .dark\:bg-red-900\/20{background-color:rgba(127,29,29,.2)}.dark .dark\:bg-yellow-900\/20{background-color:rgba(113,63,18,.2)}.dark .dark\:bg-blue-900\/20{background-color:rgba(30,58,138,.2)}.dark .dark\:bg-green-900\/20{background-color:rgba(20,83,45,.2)}.dark .dark\:bg-gray-800\/20{background-color:rgba(31,41,55,.2)}.dark .dark\:bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity))}.dark .dark\:bg-blue-900\/30{background-color:rgba(30,58,138,.3)}.dark .dark\:bg-green-900\/30{background-color:rgba(20,83,45,.3)}.dark .dark\:bg-red-900\/30{background-color:rgba(127,29,29,.3)}.dark .dark\:text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity))}.dark .dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.dark .dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.dark .dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.dark .dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity))}.dark .dark\:text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity))}.dark .dark\:text-yellow-300{--tw-text-opacity:1;color:rgb(253 224 71/var(--tw-text-opacity))}.dark .dark\:text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity))}.dark .dark\:text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity))}.dark .dark\:text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity))}.dark .dark\:text-green-300{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity))}.dark .dark\:text-blue-200{--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity))}.dark .dark\:text-red-200{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity))}.dark .dark\:text-green-200{--tw-text-opacity:1;color:rgb(187 247 208/var(--tw-text-opacity))}.dark .dark\:ring-blue-800{--tw-ring-opacity:1;--tw-ring-color:rgb(30 64 175/var(--tw-ring-opacity))}@media (hover:hover) and (pointer:fine){.dark .dark\:hover\:border-gray-700:hover{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity))}.dark .dark\:hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark .dark\:hover\:text-blue-300:hover{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity))}}@media (min-width:640px){.sm\:left-2{left:.5rem}.sm\:h-8{height:2rem}.sm\:h-10{height:2.5rem}.sm\:h-4{height:1rem}.sm\:w-\[140px\]{width:140px}.sm\:w-\[90px\]{width:90px}.sm\:w-4{width:1rem}.sm\:flex-initial{flex:0 1 auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:p-4{padding:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:px-3{padding-left:.75rem;padding-right:.75rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:pt-6{padding-top:1.5rem}.sm\:pb-4{padding-bottom:1rem}.sm\:pl-8{padding-left:2rem}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-base{font-size:1rem;line-height:1.5rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:768px){.md\:flex{display:flex}.md\:hidden{display:none}.md\:w-\[160px\]{width:160px}.md\:w-\[100px\]{width:100px}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:gap-4{gap:1rem}}.suite[data-v-88e61ed6]{transition:all .2s ease}.suite[data-v-88e61ed6]:hover{transform:translateY(-2px)}.suite-header[data-v-88e61ed6]{border-bottom:1px solid rgba(0,0,0,.05)}.dark .suite-header[data-v-88e61ed6]{border-bottom:1px solid hsla(0,0%,100%,.05)}@keyframes slideIn-477a96cc{0%{transform:translateX(-20px);opacity:0}to{transform:translateX(0);opacity:1}}#settings[data-v-477a96cc]{animation:slideIn-477a96cc .3s ease-out}#settings>div[data-v-477a96cc]{transition:all .2s ease}#settings>div[data-v-477a96cc]:hover{transform:translateY(-2px);box-shadow:0 10px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1)}.announcement-container[data-v-f1600768]{animation:slideDown-f1600768 .3s ease-out}@keyframes slideDown-f1600768{0%{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}@media (max-width:640px){.announcement-container .ml-7[data-v-f1600768]{margin-left:1.5rem}}.suite-details-container[data-v-e2a91c9e]{min-height:100vh}
================================================
FILE: web/static/index.html
================================================
{{ .UI.Title }} Enable JavaScript to view this page.
================================================
FILE: web/static/js/app.js
================================================
(function(){"use strict";var e={434:function(e,t,s){var a=s(963),l=s(252),n=s(577),r=s(262),o=s.p+"img/logo.svg",i=s(201),u=s(507),d=s(970),c=s(135),g=s(3),m=s(512),p=s(388);function v(...e){return(0,p.m6)((0,m.W)(e))}const f=["disabled"];var w={__name:"Button",props:{variant:{type:String,default:"default"},size:{type:String,default:"default"},disabled:{type:Boolean,default:!1}},setup(e){const t=(0,g.j)("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",{variants:{variant:{default:"bg-primary text-primary-foreground hover:bg-primary/90",destructive:"bg-destructive text-destructive-foreground hover:bg-destructive/90",outline:"border border-input bg-background hover:bg-accent hover:text-accent-foreground",secondary:"bg-secondary text-secondary-foreground hover:bg-secondary/80",ghost:"hover:bg-accent hover:text-accent-foreground",link:"text-primary underline-offset-4 hover:underline"},size:{default:"h-10 px-4 py-2",sm:"h-9 rounded-md px-3",lg:"h-11 rounded-md px-8",icon:"h-10 w-10"}},defaultVariants:{variant:"default",size:"default"}});return(s,a)=>((0,l.wg)(),(0,l.iD)("button",{class:(0,n.C_)((0,r.SU)(v)((0,r.SU)(t)({variant:e.variant,size:e.size}),s.$attrs.class??"")),disabled:e.disabled},[(0,l.WI)(s.$slots,"default")],10,f))}};const h=w;var x=h,b={__name:"Card",setup(e){return(e,t)=>((0,l.wg)(),(0,l.iD)("div",{class:(0,n.C_)((0,r.SU)(v)("rounded-lg border bg-card text-card-foreground shadow-sm",e.$attrs.class??""))},[(0,l.WI)(e.$slots,"default")],2))}};const y=b;var k=y,_={__name:"CardHeader",setup(e){return(e,t)=>((0,l.wg)(),(0,l.iD)("div",{class:(0,n.C_)((0,r.SU)(v)("flex flex-col space-y-1.5 p-6",e.$attrs.class??""))},[(0,l.WI)(e.$slots,"default")],2))}};const S=_;var D=S,U={__name:"CardTitle",setup(e){return(e,t)=>((0,l.wg)(),(0,l.iD)("h3",{class:(0,n.C_)((0,r.SU)(v)("text-2xl font-semibold leading-none tracking-tight",e.$attrs.class??""))},[(0,l.WI)(e.$slots,"default")],2))}};const C=U;var z=C,W={__name:"CardContent",setup(e){return(e,t)=>((0,l.wg)(),(0,l.iD)("div",{class:(0,n.C_)((0,r.SU)(v)("p-6 pt-0",e.$attrs.class??""))},[(0,l.WI)(e.$slots,"default")],2))}};const H=W;var j=H;const R={id:"social"};function F(e,t){return(0,l.wg)(),(0,l.iD)("div",R,t[0]||(t[0]=[(0,l._)("a",{href:"https://github.com/TwiN/gatus",target:"_blank",title:"Gatus on GitHub"},[(0,l._)("svg",{xmlns:"http://www.w3.org/2000/svg",width:"32",height:"32",viewBox:"0 0 16 16",class:"hover:scale-110"},[(0,l._)("path",{fill:"gray",d:"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"})])],-1)]))}var T=s(744);const E={},q=(0,T.Z)(E,[["render",F],["__scopeId","data-v-788af9ce"]]);var $=q;const L=e=>{let t=(new Date).getTime()-new Date(e).getTime();if(t<500)return"now";if(t>2592e5){let e=(t/864e5).toFixed(0);return e+" day"+("1"!==e?"s":"")+" ago"}if(t>36e5){let e=(t/36e5).toFixed(0);return e+" hour"+("1"!==e?"s":"")+" ago"}if(t>6e4){let e=(t/6e4).toFixed(0);return e+" minute"+("1"!==e?"s":"")+" ago"}let s=(t/1e3).toFixed(0);return s+" second"+("1"!==s?"s":"")+" ago"},Z=(e,t)=>{const s=new Date(e)-new Date(t),a=Math.floor(s/1e3),l=Math.floor(a/60),n=Math.floor(l/60);if(n>0){const e=l%60,t=n+(1===n?" hour":" hours");return e>0?t+" "+e+(1===e?" minute":" minutes"):t}if(l>0){const e=a%60,t=l+(1===l?" minute":" minutes");return e>0?t+" "+e+(1===e?" second":" seconds"):t}return a+(1===a?" second":" seconds")},M=e=>{let t=new Date(e),s=t.getFullYear(),a=(t.getMonth()+1<10?"0":"")+(t.getMonth()+1),l=(t.getDate()<10?"0":"")+t.getDate(),n=(t.getHours()<10?"0":"")+t.getHours(),r=(t.getMinutes()<10?"0":"")+t.getMinutes(),o=(t.getSeconds()<10?"0":"")+t.getSeconds();return s+"-"+a+"-"+l+" "+n+":"+r+":"+o},A={key:0,class:"space-y-2"},N={key:0,class:"flex items-center gap-2"},I={class:"text-xs font-semibold"},Y={class:"font-mono text-xs"},O={key:1},P={class:"font-mono text-xs"},K={key:0,class:"mt-1 space-y-0.5"},V={class:"truncate"},B={class:"text-muted-foreground"},G={key:0,class:"text-xs text-muted-foreground"},J={class:"text-xs font-semibold text-muted-foreground uppercase tracking-wider"},X={class:"font-mono text-xs"},Q={key:2},ee={class:"font-mono text-xs space-y-0.5"},te={class:"break-all"},se={key:3},ae={class:"font-mono text-xs space-y-0.5"};var le={__name:"Tooltip",props:{event:{type:[Event,Object],default:null},result:{type:Object,default:null},isPersistent:{type:Boolean,default:!1}},setup(e){const t=(0,i.yj)(),s=e,a=(0,r.iH)(!0),o=(0,r.iH)(0),u=(0,r.iH)(0),d=(0,r.iH)(null),c=(0,r.iH)(null),g=(0,l.Fl)((()=>s.result&&void 0!==s.result.endpointResults)),m=(0,l.Fl)((()=>g.value&&s.result.endpointResults?s.result.endpointResults.length:0)),p=(0,l.Fl)((()=>g.value&&s.result.endpointResults?s.result.endpointResults.filter((e=>e.success)).length:0)),v=async()=>{if(!c.value||!d.value||a.value)return;await(0,l.Y3)();const e=c.value.getBoundingClientRect(),t=d.value.getBoundingClientRect(),s=window.pageYOffset||document.documentElement.scrollTop,n=window.pageXOffset||document.documentElement.scrollLeft;let r=e.bottom+s+8,i=e.left+n;const g=window.innerHeight-e.bottom,m=e.top;gt.height+20?e.top+s-t.height-8:m>g?s+10:s+window.innerHeight-t.height-10);const p=window.innerWidth-e.left;p{if(s.event&&s.event.type)if(await(0,l.Y3)(),"mouseenter"!==s.event.type&&"click"!==s.event.type||!d.value)"mouseleave"===s.event.type&&(s.isPersistent||(a.value=!0,c.value=null));else{const e=s.event.target;c.value=e,a.value=!1,await(0,l.Y3)(),await v()}},w=()=>{v()};return(0,l.bv)((()=>{window.addEventListener("resize",w)})),(0,l.Ah)((()=>{window.removeEventListener("resize",w)})),(0,l.YP)((()=>s.event),(e=>{e&&e.type&&("mouseenter"===e.type||"click"===e.type?(a.value=!1,(0,l.Y3)((()=>f()))):"mouseleave"===e.type&&(s.isPersistent||(a.value=!0)))}),{immediate:!0}),(0,l.YP)((()=>s.result),(()=>{a.value||(0,l.Y3)((()=>f()))})),(0,l.YP)((()=>[s.isPersistent,s.result]),(([e,t])=>{e||t?t&&(e||"mouseenter"===s.event?.type)&&(a.value=!1,(0,l.Y3)((()=>f()))):a.value=!0})),(0,l.YP)((()=>t.path),(()=>{a.value=!0,c.value=null})),(t,s)=>((0,l.wg)(),(0,l.iD)("div",{id:"tooltip",ref_key:"tooltip",ref:d,class:(0,n.C_)(["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",a.value?"invisible opacity-0":"visible opacity-100"]),style:(0,n.j5)(`top: ${o.value}px; left: ${u.value}px;`)},[e.result?((0,l.wg)(),(0,l.iD)("div",A,[g.value?((0,l.wg)(),(0,l.iD)("div",N,[(0,l._)("span",{class:(0,n.C_)(["inline-block w-2 h-2 rounded-full",e.result.success?"bg-green-500":"bg-red-500"])},null,2),(0,l._)("span",I,(0,n.zw)(e.result.success?"Suite Passed":"Suite Failed"),1)])):(0,l.kq)("",!0),(0,l._)("div",null,[s[0]||(s[0]=(0,l._)("div",{class:"text-xs font-semibold text-muted-foreground uppercase tracking-wider"},"Timestamp",-1)),(0,l._)("div",Y,(0,n.zw)((0,r.SU)(M)(e.result.timestamp)),1)]),g.value&&e.result.endpointResults?((0,l.wg)(),(0,l.iD)("div",O,[s[1]||(s[1]=(0,l._)("div",{class:"text-xs font-semibold text-muted-foreground uppercase tracking-wider"},"Endpoints",-1)),(0,l._)("div",P,[(0,l._)("span",{class:(0,n.C_)(p.value===m.value?"text-green-500":"text-yellow-500")},(0,n.zw)(p.value)+"/"+(0,n.zw)(m.value)+" passed ",3)]),e.result.endpointResults.length>0?((0,l.wg)(),(0,l.iD)("div",K,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.result.endpointResults.slice(0,5),((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:"flex items-center gap-1 text-xs"},[(0,l._)("span",{class:(0,n.C_)(e.success?"text-green-500":"text-red-500")},(0,n.zw)(e.success?"✓":"✗"),3),(0,l._)("span",V,(0,n.zw)(e.name),1),(0,l._)("span",B,"("+(0,n.zw)(Math.trunc(e.duration/1e6))+"ms)",1)])))),128)),e.result.endpointResults.length>5?((0,l.wg)(),(0,l.iD)("div",G," ... and "+(0,n.zw)(e.result.endpointResults.length-5)+" more ",1)):(0,l.kq)("",!0)])):(0,l.kq)("",!0)])):(0,l.kq)("",!0),(0,l._)("div",null,[(0,l._)("div",J,(0,n.zw)(g.value?"Total Duration":"Response Time"),1),(0,l._)("div",X,(0,n.zw)(Math.trunc(e.result.duration/1e6))+"ms ",1)]),!g.value&&e.result.conditionResults&&e.result.conditionResults.length?((0,l.wg)(),(0,l.iD)("div",Q,[s[2]||(s[2]=(0,l._)("div",{class:"text-xs font-semibold text-muted-foreground uppercase tracking-wider"},"Conditions",-1)),(0,l._)("div",ee,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.result.conditionResults,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:"flex items-start gap-1"},[(0,l._)("span",{class:(0,n.C_)(e.success?"text-green-500":"text-red-500")},(0,n.zw)(e.success?"✓":"✗"),3),(0,l._)("span",te,(0,n.zw)(e.condition),1)])))),128))])])):(0,l.kq)("",!0),e.result.errors&&e.result.errors.length?((0,l.wg)(),(0,l.iD)("div",se,[s[3]||(s[3]=(0,l._)("div",{class:"text-xs font-semibold text-muted-foreground uppercase tracking-wider"},"Errors",-1)),(0,l._)("div",ae,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.result.errors,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:"text-red-500"}," • "+(0,n.zw)(e),1)))),128))])])):(0,l.kq)("",!0)])):(0,l.kq)("",!0)],6))}};const ne=le;var re=ne;const oe={class:"flex justify-center items-center"};var ie={__name:"Loading",props:{size:{type:String,default:"md",validator:e=>["xs","sm","md","lg","xl"].includes(e)}},setup(e){const t=e,s=(0,l.Fl)((()=>{const e={xs:"w-4 h-4",sm:"w-6 h-6",md:"w-8 h-8",lg:"w-12 h-12",xl:"w-16 h-16"};return e[t.size]||e.md}));return(e,t)=>((0,l.wg)(),(0,l.iD)("div",oe,[(0,l._)("img",{class:(0,n.C_)(["animate-spin rounded-full opacity-60 grayscale",s.value]),src:o,alt:"Gatus logo"},null,2)]))}};const ue=ie;var de=ue;const ce={id:"global",class:"bg-background text-foreground"},ge={key:0,class:"flex items-center justify-center min-h-screen"},me={key:1,class:"relative"},pe={class:"border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/60"},ve={class:"container mx-auto px-4 py-4 max-w-7xl"},fe={class:"flex items-center justify-between"},we={class:"flex items-center gap-4"},he={class:"w-12 h-12 flex items-center justify-center"},xe=["src"],be={key:1,src:o,alt:"Gatus",class:"w-full h-full object-contain"},ye={class:"text-2xl font-bold tracking-tight"},ke={key:0,class:"text-sm text-muted-foreground"},_e={class:"flex items-center gap-2"},Se={key:0,class:"hidden md:flex items-center gap-1"},De=["href"],Ue={key:0,class:"md:hidden mt-4 pt-4 border-t space-y-1"},Ce=["href"],ze={class:"relative"},We={class:"border-t mt-auto"},He={class:"container mx-auto px-4 py-6 max-w-7xl"},je={class:"flex flex-col items-center gap-4"},Re={key:2,id:"login-container",class:"flex items-center justify-center min-h-screen p-4"},Fe={key:0,class:"mb-6"},Te={class:"p-3 rounded-md bg-destructive/10 border border-destructive/20"},Ee={class:"text-sm text-destructive text-center"},qe={key:0},$e={key:1};var Le={__name:"App",setup(e){const t=(0,i.yj)(),s=(0,r.iH)(!1),a=(0,r.iH)({oidc:!1,authenticated:!0}),g=(0,r.iH)([]),m=(0,r.iH)({}),p=(0,r.iH)(!1),v=(0,r.iH)(!1),f=(0,r.iH)(!1);let w=null;const h=(0,l.Fl)((()=>window.config&&window.config.logo&&"{{ .UI.Logo }}"!==window.config.logo?window.config.logo:"")),b=(0,l.Fl)((()=>window.config&&window.config.header&&"{{ .UI.Header }}"!==window.config.header?window.config.header:"Gatus")),y=(0,l.Fl)((()=>window.config&&window.config.link&&"{{ .UI.Link }}"!==window.config.link?window.config.link:null)),_=(0,l.Fl)((()=>window.config&&window.config.buttons?window.config.buttons:[])),S=async()=>{try{const e=await fetch("/api/v1/config",{credentials:"include"});if(200===e.status){const t=await e.json();a.value=t,g.value=t.announcements||[]}s.value=!0}catch(e){console.error("Failed to fetch config:",e),s.value=!0}},U=(e,t,s="hover")=>{"click"===s?e?(m.value={result:e,event:t},f.value=!0):(m.value={},f.value=!1):"hover"===s&&(f.value||(m.value={result:e,event:t}))},C=e=>{if(f.value){const t=document.getElementById("tooltip"),s=e.target.closest(".flex-1.h-6, .flex-1.h-8");!t||t.contains(e.target)||s||(m.value={},f.value=!1,window.dispatchEvent(new CustomEvent("clear-data-point-selection")))}};return(0,l.bv)((()=>{S(),w=setInterval(S,6e5),document.addEventListener("click",C)})),(0,l.Ah)((()=>{w&&(clearInterval(w),w=null),document.removeEventListener("click",C)})),(e,i)=>{const w=(0,l.up)("router-view");return(0,l.wg)(),(0,l.iD)("div",ce,[s.value?a.value&&a.value.oidc&&!a.value.authenticated?((0,l.wg)(),(0,l.iD)("div",Re,[(0,l.Wm)((0,r.SU)(k),{class:"w-full max-w-md"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),{class:"text-center"},{default:(0,l.w5)((()=>[i[5]||(i[5]=(0,l._)("img",{src:o,alt:"Gatus",class:"w-20 h-20 mx-auto mb-4"},null,-1)),(0,l.Wm)((0,r.SU)(z),{class:"text-3xl"},{default:(0,l.w5)((()=>i[4]||(i[4]=[(0,l.Uk)("Gatus",-1)]))),_:1,__:[4]}),i[6]||(i[6]=(0,l._)("p",{class:"text-muted-foreground mt-2"},"System Monitoring Dashboard",-1))])),_:1,__:[5,6]}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,r.SU)(t)&&(0,r.SU)(t).query.error?((0,l.wg)(),(0,l.iD)("div",Fe,[(0,l._)("div",Te,[(0,l._)("p",Ee,["access_denied"===(0,r.SU)(t).query.error?((0,l.wg)(),(0,l.iD)("span",qe," You do not have access to this status page ")):((0,l.wg)(),(0,l.iD)("span",$e,(0,n.zw)((0,r.SU)(t).query.error),1))])])])):(0,l.kq)("",!0),(0,l._)("a",{href:"/oidc/login",class:"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 w-full",onClick:i[2]||(i[2]=e=>v.value=!0)},[v.value?((0,l.wg)(),(0,l.j4)(de,{key:0,size:"xs"})):((0,l.wg)(),(0,l.iD)(l.HY,{key:1},[(0,l.Wm)((0,r.SU)(c.Z),{class:"mr-2 h-4 w-4"}),i[7]||(i[7]=(0,l.Uk)(" Login with OIDC ",-1))],64))])])),_:1})])),_:1})])):((0,l.wg)(),(0,l.iD)("div",me,[(0,l._)("header",pe,[(0,l._)("div",ve,[(0,l._)("div",fe,[(0,l._)("div",we,[((0,l.wg)(),(0,l.j4)((0,l.LL)(y.value?"a":"div"),{href:y.value,target:"_blank",class:(0,n.C_)(["flex items-center gap-3",y.value&&"hover:opacity-80 transition-opacity"])},{default:(0,l.w5)((()=>[(0,l._)("div",he,[h.value?((0,l.wg)(),(0,l.iD)("img",{key:0,src:h.value,alt:"Gatus",class:"w-full h-full object-contain"},null,8,xe)):((0,l.wg)(),(0,l.iD)("img",be))]),(0,l._)("div",null,[(0,l._)("h1",ye,(0,n.zw)(b.value),1),_.value&&_.value.length?((0,l.wg)(),(0,l.iD)("p",ke," System Monitoring Dashboard ")):(0,l.kq)("",!0)])])),_:1},8,["href","class"]))]),(0,l._)("div",_e,[_.value&&_.value.length?((0,l.wg)(),(0,l.iD)("nav",Se,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(_.value,(e=>((0,l.wg)(),(0,l.iD)("a",{key:e.name,href:e.link,target:"_blank",class:"px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"},(0,n.zw)(e.name),9,De)))),128))])):(0,l.kq)("",!0),_.value&&_.value.length?((0,l.wg)(),(0,l.j4)((0,r.SU)(x),{key:1,variant:"ghost",size:"icon",class:"md:hidden",onClick:i[0]||(i[0]=e=>p.value=!p.value)},{default:(0,l.w5)((()=>[p.value?((0,l.wg)(),(0,l.j4)((0,r.SU)(d.Z),{key:1,class:"h-5 w-5"})):((0,l.wg)(),(0,l.j4)((0,r.SU)(u.Z),{key:0,class:"h-5 w-5"}))])),_:1})):(0,l.kq)("",!0)])]),_.value&&_.value.length&&p.value?((0,l.wg)(),(0,l.iD)("nav",Ue,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(_.value,(e=>((0,l.wg)(),(0,l.iD)("a",{key:e.name,href:e.link,target:"_blank",class:"block px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors",onClick:i[1]||(i[1]=e=>p.value=!1)},(0,n.zw)(e.name),9,Ce)))),128))])):(0,l.kq)("",!0)])]),(0,l._)("main",ze,[(0,l.Wm)(w,{onShowTooltip:U,announcements:g.value},null,8,["announcements"])]),(0,l._)("footer",We,[(0,l._)("div",He,[(0,l._)("div",je,[i[3]||(i[3]=(0,l._)("div",{class:"text-sm text-muted-foreground text-center"},[(0,l.Uk)(" Powered by "),(0,l._)("a",{href:"https://gatus.io",target:"_blank",class:"font-medium text-emerald-800 hover:text-emerald-600"},"Gatus")],-1)),(0,l.Wm)($)])])])])):((0,l.wg)(),(0,l.iD)("div",ge,[(0,l.Wm)(de,{size:"lg"})])),(0,l.Wm)(re,{result:m.value.result,event:m.value.event,isPersistent:f.value},null,8,["result","event","isPersistent"])])}}};const Ze=Le;var Me=Ze,Ae=s(793),Ne=s(138),Ie=s(254),Ye=s(146),Oe=s(485),Pe=s(893),Ke=s(89),Ve=s(372),Be=s(981),Ge={__name:"Badge",props:{variant:{type:String,default:"default"}},setup(e){const t=(0,g.j)("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",secondary:"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",destructive:"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",outline:"text-foreground",success:"border-transparent bg-green-500 text-white",warning:"border-transparent bg-yellow-500 text-white"}},defaultVariants:{variant:"default"}});return(s,a)=>((0,l.wg)(),(0,l.iD)("div",{class:(0,n.C_)((0,r.SU)(v)((0,r.SU)(t)({variant:e.variant}),s.$attrs.class??""))},[(0,l.WI)(s.$slots,"default")],2))}};const Je=Ge;var Xe=Je,Qe={__name:"StatusBadge",props:{status:{type:String,required:!0,validator:e=>["healthy","unhealthy","degraded","unknown"].includes(e)}},setup(e){const t=e,s=(0,l.Fl)((()=>{switch(t.status){case"healthy":return"success";case"unhealthy":return"destructive";case"degraded":return"warning";default:return"secondary"}})),a=(0,l.Fl)((()=>{switch(t.status){case"healthy":return"Healthy";case"unhealthy":return"Unhealthy";case"degraded":return"Degraded";default:return"Unknown"}})),o=(0,l.Fl)((()=>{switch(t.status){case"healthy":return"bg-green-400";case"unhealthy":return"bg-red-400";case"degraded":return"bg-yellow-400";default:return"bg-gray-400"}}));return(e,t)=>((0,l.wg)(),(0,l.j4)((0,r.SU)(Xe),{variant:s.value,class:"flex items-center gap-1"},{default:(0,l.w5)((()=>[(0,l._)("span",{class:(0,n.C_)(["w-2 h-2 rounded-full",o.value])},null,2),(0,l.Uk)(" "+(0,n.zw)(a.value),1)])),_:1},8,["variant"]))}};const et=Qe;var tt=et;const st={class:"flex items-start justify-between gap-2 sm:gap-3"},at={class:"flex-1 min-w-0 overflow-hidden"},lt=["title","aria-label"],nt={class:"flex items-center gap-2 text-xs sm:text-sm text-muted-foreground min-h-[1.25rem]"},rt=["title"],ot={key:1},it=["title"],ut={class:"flex-shrink-0 ml-2"},dt={class:"space-y-2"},ct={class:"flex items-center justify-between mb-1"},gt=["title"],mt={class:"flex gap-0.5"},pt=["onMouseenter","onMouseleave","onClick"],vt={class:"flex items-center justify-between text-xs text-muted-foreground mt-1"};var ft={__name:"EndpointCard",props:{endpoint:{type:Object,required:!0},maxResults:{type:Number,default:50},showAverageResponseTime:{type:Boolean,default:!0}},emits:["showTooltip"],setup(e,{emit:t}){const s=(0,i.tv)(),o=e,u=t,d=(0,r.iH)(null),c=(0,l.Fl)((()=>o.endpoint.results&&0!==o.endpoint.results.length?o.endpoint.results[o.endpoint.results.length-1]:null)),g=(0,l.Fl)((()=>c.value?c.value.success?"healthy":"unhealthy":"unknown")),m=(0,l.Fl)((()=>c.value?.hostname||null)),p=(0,l.Fl)((()=>{const e=[...o.endpoint.results||[]];while(e.length{if(!o.endpoint.results||0===o.endpoint.results.length)return"N/A";let e=0,t=0,s=1/0,a=0;for(const l of o.endpoint.results)if(l.duration){const n=l.duration/1e6;e+=n,t++,s=Math.min(s,n),a=Math.max(a,n)}if(0===t)return"N/A";if(o.showAverageResponseTime){const s=Math.round(e/t);return`~${s}ms`}{const e=Math.trunc(s),t=Math.trunc(a);return e===t?`${e}ms`:`${e}-${t}ms`}})),f=(0,l.Fl)((()=>{if(!o.endpoint.results||0===o.endpoint.results.length)return"";const e=Math.max(0,o.endpoint.results.length-o.maxResults);return L(o.endpoint.results[e].timestamp)})),w=(0,l.Fl)((()=>o.endpoint.results&&0!==o.endpoint.results.length?L(o.endpoint.results[o.endpoint.results.length-1].timestamp):"")),h=()=>{s.push(`/endpoints/${o.endpoint.key}`)},x=(e,t)=>{u("showTooltip",e,t,"hover")},b=(e,t)=>{u("showTooltip",null,t,"hover")},y=(e,t,s)=>{window.dispatchEvent(new CustomEvent("clear-data-point-selection")),d.value===s?(d.value=null,u("showTooltip",null,t,"click")):(d.value=s,u("showTooltip",e,t,"click"))},_=()=>{d.value=null};return(0,l.bv)((()=>{window.addEventListener("clear-data-point-selection",_)})),(0,l.Ah)((()=>{window.removeEventListener("clear-data-point-selection",_)})),(t,s)=>((0,l.wg)(),(0,l.j4)((0,r.SU)(k),{class:"endpoint h-full flex flex-col transition hover:shadow-lg hover:scale-[1.01] dark:hover:border-gray-700"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),{class:"endpoint-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0"},{default:(0,l.w5)((()=>[(0,l._)("div",st,[(0,l._)("div",at,[(0,l.Wm)((0,r.SU)(z),{class:"text-base sm:text-lg truncate"},{default:(0,l.w5)((()=>[(0,l._)("span",{class:"hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate",onClick:h,onKeydown:(0,a.D2)(h,["enter"]),title:e.endpoint.name,role:"link",tabindex:"0","aria-label":`View details for ${e.endpoint.name}`},(0,n.zw)(e.endpoint.name),41,lt)])),_:1}),(0,l._)("div",nt,[e.endpoint.group?((0,l.wg)(),(0,l.iD)("span",{key:0,class:"truncate",title:e.endpoint.group},(0,n.zw)(e.endpoint.group),9,rt)):(0,l.kq)("",!0),e.endpoint.group&&m.value?((0,l.wg)(),(0,l.iD)("span",ot,"•")):(0,l.kq)("",!0),m.value?((0,l.wg)(),(0,l.iD)("span",{key:2,class:"truncate",title:m.value},(0,n.zw)(m.value),9,it)):(0,l.kq)("",!0)])]),(0,l._)("div",ut,[(0,l.Wm)(tt,{status:g.value},null,8,["status"])])])])),_:1}),(0,l.Wm)((0,r.SU)(j),{class:"endpoint-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2"},{default:(0,l.w5)((()=>[(0,l._)("div",dt,[(0,l._)("div",null,[(0,l._)("div",ct,[s[0]||(s[0]=(0,l._)("div",{class:"flex-1"},null,-1)),(0,l._)("p",{class:"text-xs text-muted-foreground",title:e.showAverageResponseTime?"Average response time":"Minimum and maximum response time"},(0,n.zw)(v.value),9,gt)]),(0,l._)("div",mt,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(p.value,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:(0,n.C_)(["flex-1 h-6 sm:h-8 rounded-sm transition-all",e?"cursor-pointer":"",e?e.success?d.value===t?"bg-green-700":"bg-green-500 hover:bg-green-700":d.value===t?"bg-red-700":"bg-red-500 hover:bg-red-700":"bg-gray-200 dark:bg-gray-700"]),onMouseenter:t=>e&&x(e,t),onMouseleave:t=>e&&b(e,t),onClick:(0,a.iM)((s=>e&&y(e,s,t)),["stop"])},null,42,pt)))),128))]),(0,l._)("div",vt,[(0,l._)("span",null,(0,n.zw)(f.value),1),(0,l._)("span",null,(0,n.zw)(w.value),1)])])])])),_:1})])),_:1}))}};const wt=ft;var ht=wt;const xt={class:"flex items-start justify-between gap-2 sm:gap-3"},bt={class:"flex-1 min-w-0 overflow-hidden"},yt=["title","aria-label"],kt={class:"flex items-center gap-2 text-xs sm:text-sm text-muted-foreground"},_t=["title"],St={key:1},Dt={key:2},Ut={class:"flex-shrink-0 ml-2"},Ct={class:"space-y-2"},zt={class:"flex items-center justify-between mb-1"},Wt={class:"text-xs text-muted-foreground"},Ht={key:0,class:"text-xs text-muted-foreground"},jt={class:"flex gap-0.5"},Rt=["onMouseenter","onMouseleave","onClick"],Ft={class:"flex items-center justify-between text-xs text-muted-foreground mt-1"};var Tt={__name:"SuiteCard",props:{suite:{type:Object,required:!0},maxResults:{type:Number,default:50}},emits:["showTooltip"],setup(e,{emit:t}){const s=(0,i.tv)(),o=e,u=t,d=(0,r.iH)(null),c=(0,l.Fl)((()=>{const e=[...o.suite.results||[]];while(e.lengtho.suite.results&&0!==o.suite.results.length?o.suite.results[o.suite.results.length-1].success?"healthy":"unhealthy":"unknown")),m=(0,l.Fl)((()=>{if(!o.suite.results||0===o.suite.results.length)return 0;const e=o.suite.results[o.suite.results.length-1];return e.endpointResults?e.endpointResults.length:0})),p=(0,l.Fl)((()=>{if(!o.suite.results||0===o.suite.results.length)return 0;const e=o.suite.results.filter((e=>e.success)).length;return Math.round(e/o.suite.results.length*100)})),v=(0,l.Fl)((()=>{if(!o.suite.results||0===o.suite.results.length)return null;const e=o.suite.results.reduce(((e,t)=>e+(t.duration||0)),0);return Math.trunc(e/o.suite.results.length/1e6)})),f=(0,l.Fl)((()=>{if(!o.suite.results||0===o.suite.results.length)return"N/A";const e=o.suite.results[0];return L(e.timestamp)})),w=(0,l.Fl)((()=>{if(!o.suite.results||0===o.suite.results.length)return"Now";const e=o.suite.results[o.suite.results.length-1];return L(e.timestamp)})),h=()=>{s.push(`/suites/${o.suite.key}`)},x=(e,t)=>{u("showTooltip",e,t,"hover")},b=(e,t)=>{u("showTooltip",null,t,"hover")},y=(e,t,s)=>{window.dispatchEvent(new CustomEvent("clear-data-point-selection")),d.value===s?(d.value=null,u("showTooltip",null,t,"click")):(d.value=s,u("showTooltip",e,t,"click"))},_=()=>{d.value=null};return(0,l.bv)((()=>{window.addEventListener("clear-data-point-selection",_)})),(0,l.Ah)((()=>{window.removeEventListener("clear-data-point-selection",_)})),(t,s)=>((0,l.wg)(),(0,l.j4)((0,r.SU)(k),{class:"suite h-full flex flex-col transition hover:shadow-lg hover:scale-[1.01] dark:hover:border-gray-700"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),{class:"suite-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0"},{default:(0,l.w5)((()=>[(0,l._)("div",xt,[(0,l._)("div",bt,[(0,l.Wm)((0,r.SU)(z),{class:"text-base sm:text-lg truncate"},{default:(0,l.w5)((()=>[(0,l._)("span",{class:"hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate",onClick:h,onKeydown:(0,a.D2)(h,["enter"]),title:e.suite.name,role:"link",tabindex:"0","aria-label":`View details for suite ${e.suite.name}`},(0,n.zw)(e.suite.name),41,yt)])),_:1}),(0,l._)("div",kt,[e.suite.group?((0,l.wg)(),(0,l.iD)("span",{key:0,class:"truncate",title:e.suite.group},(0,n.zw)(e.suite.group),9,_t)):(0,l.kq)("",!0),e.suite.group&&m.value?((0,l.wg)(),(0,l.iD)("span",St,"•")):(0,l.kq)("",!0),m.value?((0,l.wg)(),(0,l.iD)("span",Dt,(0,n.zw)(m.value)+" endpoint"+(0,n.zw)(1!==m.value?"s":""),1)):(0,l.kq)("",!0)])]),(0,l._)("div",Ut,[(0,l.Wm)(tt,{status:g.value},null,8,["status"])])])])),_:1}),(0,l.Wm)((0,r.SU)(j),{class:"suite-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2"},{default:(0,l.w5)((()=>[(0,l._)("div",Ct,[(0,l._)("div",null,[(0,l._)("div",zt,[(0,l._)("p",Wt,"Success Rate: "+(0,n.zw)(p.value)+"%",1),null!==v.value?((0,l.wg)(),(0,l.iD)("p",Ht,(0,n.zw)(v.value)+"ms avg",1)):(0,l.kq)("",!0)]),(0,l._)("div",jt,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(c.value,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:(0,n.C_)(["flex-1 h-6 sm:h-8 rounded-sm transition-all",e?"cursor-pointer":"",e?e.success?d.value===t?"bg-green-700":"bg-green-500 hover:bg-green-700":d.value===t?"bg-red-700":"bg-red-500 hover:bg-red-700":"bg-gray-200 dark:bg-gray-700"]),onMouseenter:t=>e&&x(e,t),onMouseleave:t=>e&&b(e,t),onClick:(0,a.iM)((s=>e&&y(e,s,t)),["stop"])},null,42,Rt)))),128))]),(0,l._)("div",Ft,[(0,l._)("span",null,(0,n.zw)(f.value),1),(0,l._)("span",null,(0,n.zw)(w.value),1)])])])])),_:1})])),_:1}))}};const Et=(0,T.Z)(Tt,[["__scopeId","data-v-88e61ed6"]]);var qt=Et,$t=s(275);const Lt=["value"];var Zt={__name:"Input",props:{modelValue:{type:[String,Number],default:""}},emits:["update:modelValue"],setup(e){return(t,s)=>((0,l.wg)(),(0,l.iD)("input",{class:(0,n.C_)((0,r.SU)(v)("flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",t.$attrs.class??"")),value:e.modelValue,onInput:s[0]||(s[0]=e=>t.$emit("update:modelValue",e.target.value))},null,42,Lt))}};const Mt=Zt;var At=Mt,Nt=s(368);const It=["aria-expanded","aria-label"],Yt={class:"truncate"},Ot={key:0,role:"listbox",class:"absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"},Pt={class:"p-1"},Kt=["onClick","aria-selected"],Vt={class:"absolute left-1.5 sm:left-2 flex h-3.5 w-3.5 items-center justify-center"};var Bt={__name:"Select",props:{modelValue:{type:String,default:""},options:{type:Array,required:!0},placeholder:{type:String,default:"Select..."},class:{type:String,default:""}},emits:["update:modelValue"],setup(e,{emit:t}){const s=e,a=t,o=(0,r.iH)(!1),i=(0,r.iH)(null),u=(0,r.iH)(-1),d=(0,l.Fl)((()=>s.options.find((e=>e.value===s.modelValue))||{label:s.placeholder,value:""})),c=e=>{a("update:modelValue",e.value),o.value=!1},g=()=>{if(o.value=!o.value,o.value){const e=s.options.findIndex((e=>e.value===s.modelValue));u.value=e>=0?e:0}else u.value=-1},m=e=>{i.value&&!i.value.contains(e.target)&&(o.value=!1,u.value=-1)},p=e=>{if(o.value)switch(e.key){case"ArrowDown":e.preventDefault(),u.value=Math.min(u.value+1,s.options.length-1);break;case"ArrowUp":e.preventDefault(),u.value=Math.max(u.value-1,0);break;case"Enter":case" ":e.preventDefault(),u.value>=0&&u.value{document.addEventListener("click",m)})),(0,l.Ah)((()=>{document.removeEventListener("click",m)})),(t,a)=>((0,l.wg)(),(0,l.iD)("div",{ref_key:"selectRef",ref:i,class:(0,n.C_)(["relative",s.class])},[(0,l._)("button",{onClick:g,onKeydown:p,"aria-expanded":o.value,"aria-haspopup":!0,"aria-label":d.value.label||s.placeholder,class:"flex h-9 sm:h-10 w-full items-center justify-between rounded-md border border-input bg-background px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"},[(0,l._)("span",Yt,(0,n.zw)(d.value.label),1),(0,l.Wm)((0,r.SU)(Oe.Z),{class:"h-3 w-3 sm:h-4 sm:w-4 opacity-50 flex-shrink-0 ml-1"})],40,It),o.value?((0,l.wg)(),(0,l.iD)("div",Ot,[(0,l._)("div",Pt,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.options,((t,s)=>((0,l.wg)(),(0,l.iD)("div",{key:t.value,onClick:e=>c(t),class:(0,n.C_)(["relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-6 sm:pl-8 pr-2 text-xs sm:text-sm outline-none hover:bg-accent hover:text-accent-foreground",s===u.value&&"bg-accent text-accent-foreground"]),role:"option","aria-selected":e.modelValue===t.value},[(0,l._)("span",Vt,[e.modelValue===t.value?((0,l.wg)(),(0,l.j4)((0,r.SU)(Nt.Z),{key:0,class:"h-3 w-3 sm:h-4 sm:w-4"})):(0,l.kq)("",!0)]),(0,l.Uk)(" "+(0,n.zw)(t.label),1)],10,Kt)))),128))])])):(0,l.kq)("",!0)],2))}};const Gt=Bt;var Jt=Gt;const Xt={class:"flex flex-col lg:flex-row gap-3 lg:gap-4 p-3 sm:p-4 bg-card rounded-lg border"},Qt={class:"flex-1"},es={class:"relative"},ts={class:"flex flex-col sm:flex-row gap-3 sm:gap-4"},ss={class:"flex items-center gap-2 flex-1 sm:flex-initial"},as={class:"flex items-center gap-2 flex-1 sm:flex-initial"};var ls={__name:"SearchBar",emits:["search","update:showOnlyFailing","update:showRecentFailures","update:groupByGroup","update:sortBy","initializeCollapsedGroups"],setup(e,{emit:t}){const s=(0,r.iH)(""),a=(0,r.iH)(localStorage.getItem("gatus:filter-by")||"undefined"!==typeof window&&window.config?.defaultFilterBy||"none"),n=(0,r.iH)(localStorage.getItem("gatus:sort-by")||"undefined"!==typeof window&&window.config?.defaultSortBy||"name"),o=[{label:"None",value:"none"},{label:"Failing",value:"failing"},{label:"Unstable",value:"unstable"}],i=[{label:"Name",value:"name"},{label:"Group",value:"group"},{label:"Health",value:"health"}],u=t,d=(e,t=!0)=>{a.value=e,t&&localStorage.setItem("gatus:filter-by",e),u("update:showOnlyFailing",!1),u("update:showRecentFailures",!1),"failing"===e?u("update:showOnlyFailing",!0):"unstable"===e&&u("update:showRecentFailures",!0)},c=(e,t=!0)=>{n.value=e,t&&localStorage.setItem("gatus:sort-by",e),u("update:sortBy",e),u("update:groupByGroup","group"===e),"group"===e&&u("initializeCollapsedGroups")};return(0,l.bv)((()=>{d(a.value,!1),c(n.value,!1)})),(e,t)=>((0,l.wg)(),(0,l.iD)("div",Xt,[(0,l._)("div",Qt,[(0,l._)("div",es,[(0,l.Wm)((0,r.SU)($t.Z),{class:"absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"}),t[4]||(t[4]=(0,l._)("label",{for:"search-input",class:"sr-only"},"Search endpoints",-1)),(0,l.Wm)((0,r.SU)(At),{id:"search-input",modelValue:s.value,"onUpdate:modelValue":t[0]||(t[0]=e=>s.value=e),type:"text",placeholder:"Search endpoints...",class:"pl-10 text-sm sm:text-base",onInput:t[1]||(t[1]=t=>e.$emit("search",s.value))},null,8,["modelValue"])])]),(0,l._)("div",ts,[(0,l._)("div",ss,[t[5]||(t[5]=(0,l._)("label",{class:"text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap"},"Filter by:",-1)),(0,l.Wm)((0,r.SU)(Jt),{modelValue:a.value,"onUpdate:modelValue":[t[2]||(t[2]=e=>a.value=e),d],options:o,placeholder:"None",class:"flex-1 sm:w-[140px] md:w-[160px]"},null,8,["modelValue"])]),(0,l._)("div",as,[t[6]||(t[6]=(0,l._)("label",{class:"text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap"},"Sort by:",-1)),(0,l.Wm)((0,r.SU)(Jt),{modelValue:n.value,"onUpdate:modelValue":[t[3]||(t[3]=e=>n.value=e),c],options:i,placeholder:"Name",class:"flex-1 sm:w-[90px] md:w-[100px]"},null,8,["modelValue"])])])]))}};const ns=ls;var rs=ns,os=s(789),is=s(679);const us={id:"settings",class:"fixed bottom-4 left-4 z-50"},ds={class:"flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-full shadow-md p-1"},cs=["aria-label","aria-expanded"],gs={class:"text-xs font-medium"},ms=["onClick"],ps=["aria-label"],vs={class:"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded-md shadow-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap"},fs="300",ws="theme",hs=31536e3;var xs={__name:"Settings",emits:["refreshData"],setup(e,{emit:t}){const s=t,o=[{value:"10",label:"10s"},{value:"30",label:"30s"},{value:"60",label:"1m"},{value:"120",label:"2m"},{value:"300",label:"5m"},{value:"600",label:"10m"}],i={REFRESH_INTERVAL:"gatus:refresh-interval"};function u(){const e=document.cookie.match(new RegExp(`${ws}=(dark|light);?`))?.[1];return"dark"===e||!e&&(window.matchMedia("(prefers-color-scheme: dark)").matches||document.documentElement.classList.contains("dark"))}function d(){const e=localStorage.getItem(i.REFRESH_INTERVAL),t=e&&parseInt(e),s=t&&t>=10&&o.some((t=>t.value===e));return s?e:fs}const c=(0,r.iH)(d()),g=(0,r.iH)(u()),m=(0,r.iH)(!1);let p=null;const v=e=>{const t=o.find((t=>t.value===e));return t?t.label:`${e}s`},f=e=>{localStorage.setItem(i.REFRESH_INTERVAL,e),p&&clearInterval(p),p=setInterval((()=>{w()}),1e3*e)},w=()=>{s("refreshData")},h=e=>{c.value=e,m.value=!1,w(),f(e)},x=e=>{const t=document.getElementById("settings");t&&!t.contains(e.target)&&(m.value=!1)},b=e=>{document.cookie=`${ws}=${e}; path=/; max-age=${hs}; samesite=strict`},y=()=>{const e=u()?"light":"dark";b(e),k()},k=()=>{const e=u();g.value=e,document.documentElement.classList.toggle("dark",e)};return(0,l.bv)((()=>{f(c.value),k(),document.addEventListener("click",x)})),(0,l.Ah)((()=>{p&&clearInterval(p),document.removeEventListener("click",x)})),(e,t)=>((0,l.wg)(),(0,l.iD)("div",us,[(0,l._)("div",ds,[(0,l._)("button",{onClick:t[1]||(t[1]=e=>m.value=!m.value),"aria-label":`Refresh interval: ${v(c.value)}`,"aria-expanded":m.value,class:"flex items-center gap-1.5 px-3 py-1.5 rounded-full hover:bg-accent transition-colors relative"},[(0,l.Wm)((0,r.SU)(Ie.Z),{class:"w-3.5 h-3.5 text-muted-foreground"}),(0,l._)("span",gs,(0,n.zw)(v(c.value)),1),m.value?((0,l.wg)(),(0,l.iD)("div",{key:0,onClick:t[0]||(t[0]=(0,a.iM)((()=>{}),["stop"])),class:"absolute bottom-full left-0 mb-2 bg-popover border rounded-lg shadow-lg overflow-hidden"},[((0,l.wg)(),(0,l.iD)(l.HY,null,(0,l.Ko)(o,(e=>(0,l._)("button",{key:e.value,onClick:t=>h(e.value),class:(0,n.C_)(["block w-full px-4 py-2 text-xs text-left hover:bg-accent transition-colors",c.value===e.value&&"bg-accent"])},(0,n.zw)(e.label),11,ms))),64))])):(0,l.kq)("",!0)],8,cs),t[2]||(t[2]=(0,l._)("div",{class:"h-5 w-px bg-border/50"},null,-1)),(0,l._)("button",{onClick:y,"aria-label":g.value?"Switch to light mode":"Switch to dark mode",class:"p-1.5 rounded-full hover:bg-accent transition-colors group relative"},[g.value?((0,l.wg)(),(0,l.j4)((0,r.SU)(os.Z),{key:0,class:"h-3.5 w-3.5 transition-all"})):((0,l.wg)(),(0,l.j4)((0,r.SU)(is.Z),{key:1,class:"h-3.5 w-3.5 transition-all"})),(0,l._)("div",vs,(0,n.zw)(g.value?"Light mode":"Dark mode"),1)],8,ps)])]))}};const bs=(0,T.Z)(xs,[["__scopeId","data-v-477a96cc"]]);var ys=bs,ks=s(691),_s=s(446),Ss=s(5),Ds=s(337),Us=s(441),Cs=s(424);const zs=e=>null===e||void 0===e?"":String(e).replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'"),Ws=new Us.TU.Renderer;Ws.link=(e,t,s)=>{const a="object"===typeof e&&null!==e?e:null,l=a?a.href:e,n=a?a.title:t,r=a?a.text:s,o=zs(l||""),i=n?` title="${zs(n)}"`:"",u=r||"";return`${u} `},Us.TU.use({renderer:Ws,breaks:!0,gfm:!0,headerIds:!1,mangle:!1});const Hs=e=>{if(!e)return"";const t=String(e),s=Us.TU.parse(t);return Cs.Z.sanitize(s,{ADD_ATTR:["target","rel"]})},js={key:0,class:"announcement-container mb-6"},Rs={class:"flex items-center justify-between"},Fs={class:"flex items-center gap-2"},Ts={class:"text-xs text-gray-500 dark:text-gray-400"},Es={key:0,class:"announcement-content p-4 transition-all duration-200 rounded-b-lg"},qs={class:"relative"},$s={class:"space-y-3"},Ls={class:"flex items-center gap-3 mb-2 relative"},Zs={class:"relative z-10 bg-white dark:bg-gray-800 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-600"},Ms={class:"text-sm font-medium text-gray-600 dark:text-gray-300"},As={class:"space-y-2 ml-7 relative"},Ns={key: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)"}},Is={class:"flex items-center gap-3"},Ys=["title"],Os={class:"flex-1 min-w-0"},Ps=["innerHTML"];var Ks={__name:"AnnouncementBanner",props:{announcements:{type:Array,default:()=>[]}},setup(e){const t=e,s=(0,r.iH)(!1),a=()=>{s.value=!s.value},o={outage:{icon:ks.Z,background:"bg-red-50 border-gray-200 dark:bg-red-900/50 dark:border-gray-600",border:"border-red-500",iconColor:"text-red-600 dark:text-red-400",text:"text-red-700 dark:text-red-300"},warning:{icon:_s.Z,background:"bg-yellow-50 border-gray-200 dark:bg-yellow-900/50 dark:border-gray-600",border:"border-yellow-500",iconColor:"text-yellow-600 dark:text-yellow-400",text:"text-yellow-700 dark:text-yellow-300"},information:{icon:Ss.Z,background:"bg-blue-50 border-gray-200 dark:bg-blue-900/50 dark:border-gray-600",border:"border-blue-500",iconColor:"text-blue-600 dark:text-blue-400",text:"text-blue-700 dark:text-blue-300"},operational:{icon:Ke.Z,background:"bg-green-50 border-gray-200 dark:bg-green-900/50 dark:border-gray-600",border:"border-green-500",iconColor:"text-green-600 dark:text-green-400",text:"text-green-700 dark:text-green-300"},none:{icon:Ds.Z,background:"bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-600",border:"border-gray-500",iconColor:"text-gray-600 dark:text-gray-400",text:"text-gray-700 dark:text-gray-300"}},i=(0,l.Fl)((()=>t.announcements&&t.announcements.length>0?t.announcements[0]:null)),u=(0,l.Fl)((()=>{const e=i.value?.type||"none";return o[e]?.icon||Ds.Z})),d=(0,l.Fl)((()=>{const e=i.value?.type||"none";return o[e]?.iconColor||"text-gray-600 dark:text-gray-400"})),c=(0,l.Fl)((()=>{const e=i.value?.type||"none",t=o[e];return`border-l-4 ${t.border.replace("border-","border-l-")}`})),g=(0,l.Fl)((()=>{if(!t.announcements||0===t.announcements.length)return{};const e={};return t.announcements.forEach((t=>{const s=new Date(t.timestamp).toDateString();e[s]||(e[s]=[]),e[s].push(t)})),e})),m=e=>o[e]?.icon||Ds.Z,p=e=>o[e]||o.none,v=e=>{const t=new Date(e),s=new Date,a=new Date(s);return a.setDate(a.getDate()-1),t.toDateString()===s.toDateString()?"Today":t.toDateString()===a.toDateString()?"Yesterday":t.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})},f=e=>new Date(e).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit",hour12:!1}),w=e=>new Date(e).toLocaleString("en-US",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"});return(t,o)=>e.announcements&&e.announcements.length?((0,l.wg)(),(0,l.iD)("div",js,[(0,l._)("div",{class:(0,n.C_)(["rounded-lg border bg-card text-card-foreground shadow-sm transition-all duration-200",c.value])},[(0,l._)("div",{class:(0,n.C_)(["announcement-header px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors",s.value?"rounded-lg":"rounded-t-lg border-b border-gray-200 dark:border-gray-600"]),onClick:a},[(0,l._)("div",Rs,[(0,l._)("div",Fs,[((0,l.wg)(),(0,l.j4)((0,l.LL)(u.value),{class:(0,n.C_)(["w-5 h-5",d.value])},null,8,["class"])),o[0]||(o[0]=(0,l._)("h2",{class:"text-base font-semibold text-gray-900 dark:text-gray-100"},"Announcements",-1)),(0,l._)("span",Ts," ("+(0,n.zw)(e.announcements.length)+") ",1)]),(0,l.Wm)((0,r.SU)(Oe.Z),{class:(0,n.C_)(["w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform duration-200",s.value?"-rotate-90":"rotate-0"])},null,8,["class"])])],2),s.value?(0,l.kq)("",!0):((0,l.wg)(),(0,l.iD)("div",Es,[(0,l._)("div",qs,[(0,l._)("div",$s,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(g.value,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:"relative"},[(0,l._)("div",Ls,[(0,l._)("div",Zs,[(0,l._)("time",Ms,(0,n.zw)(v(t)),1)]),o[1]||(o[1]=(0,l._)("div",{class:"flex-1 border-t border-gray-200 dark:border-gray-600"},null,-1))]),(0,l._)("div",As,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e,((s,a)=>((0,l.wg)(),(0,l.iD)("div",{key:`${t}-${a}-${s.timestamp}`,class:"relative"},[(0,l._)("div",{class:(0,n.C_)(["absolute -left-[26px] w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10",a===e.length-1?"top-3":"top-1/2 -translate-y-1/2",p(s.type).border])},[((0,l.wg)(),(0,l.j4)((0,l.LL)(m(s.type)),{class:(0,n.C_)(["w-3 h-3",p(s.type).iconColor])},null,8,["class"]))],2),0===a?((0,l.wg)(),(0,l.iD)("div",Ns)):(0,l.kq)("",!0),a[]}},setup(e){const t=e,s=(0,r.iH)(!1),a={outage:{icon:ks.Z,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:_s.Z,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:Ss.Z,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:Ke.Z,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:Ds.Z,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"}},o=e=>{const t=new Date(e);return t.setHours(0,0,0,0),t},i=(0,l.Fl)((()=>{if(!t.announcements?.length)return{};const e={};let a=new Date;t.announcements.forEach((t=>{const s=new Date(t.timestamp),l=s.toDateString();e[l]=e[l]||[],e[l].push(t),s=n;t.setDate(t.getDate()-1))r[t.toDateString()]=e[t.toDateString()]||[];return r})),u=(0,l.Fl)((()=>{if(!t.announcements?.length)return!1;const e=new Date(o(new Date).getTime()-12096e5);return t.announcements.some((t=>new Date(t.timestamp)a[e]?.icon||Ds.Z,c=e=>a[e]||a.none,g=e=>{const t=new Date(e);return t.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})},m=e=>new Date(e).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit",hour12:!1}),p=e=>new Date(e).toLocaleString("en-US",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit",timeZoneName:"short"});return(t,a)=>e.announcements&&e.announcements.length?((0,l.wg)(),(0,l.iD)("div",Gs,[a[3]||(a[3]=(0,l._)("h2",{class:"text-2xl font-semibold text-foreground mb-6"},"Past Announcements",-1)),(0,l._)("div",Js,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(i.value,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t},[(0,l._)("div",Xs,[(0,l._)("h3",Qs,(0,n.zw)(g(t)),1)]),e.length>0?((0,l.wg)(),(0,l.iD)("div",ea,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e,((e,s)=>((0,l.wg)(),(0,l.iD)("div",{key:`${t}-${s}-${e.timestamp}`,class:(0,n.C_)(["border-l-4 p-4 transition-all duration-200",c(e.type).background,c(e.type).borderColor])},[(0,l._)("div",ta,[((0,l.wg)(),(0,l.j4)((0,l.LL)(d(e.type)),{class:(0,n.C_)(["w-5 h-5 flex-shrink-0 mt-0.5",c(e.type).iconColor])},null,8,["class"])),(0,l._)("time",{class:(0,n.C_)(["text-sm font-mono whitespace-nowrap flex-shrink-0 mt-0.5",c(e.type).text]),title:p(e.timestamp)},(0,n.zw)(m(e.timestamp)),11,sa),(0,l._)("div",aa,[(0,l._)("p",{class:"text-sm leading-relaxed text-gray-900 dark:text-gray-100",innerHTML:(0,r.SU)(Hs)(e.message)},null,8,la)])])],2)))),128))])):((0,l.wg)(),(0,l.iD)("div",na,a[1]||(a[1]=[(0,l._)("p",{class:"text-sm italic text-muted-foreground/60"}," No incidents reported on this day ",-1)])))])))),128)),u.value&&!s.value?((0,l.wg)(),(0,l.iD)("div",ra,[(0,l._)("button",{onClick:a[0]||(a[0]=e=>s.value=!0),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"},[(0,l.Wm)((0,r.SU)(Oe.Z),{class:"w-4 h-4 group-hover:translate-y-0.5 transition-transform duration-200"}),a[2]||(a[2]=(0,l._)("span",{class:"group-hover:underline"},"View older announcements",-1))])])):(0,l.kq)("",!0)])])):(0,l.kq)("",!0)}};const ia=oa;var ua=ia;const da={class:"dashboard-container bg-background"},ca={class:"container mx-auto px-4 py-8 max-w-7xl"},ga={class:"mb-6"},ma={class:"flex items-center justify-between mb-6"},pa={class:"text-4xl font-bold tracking-tight"},va={class:"text-muted-foreground mt-2"},fa={class:"flex items-center gap-4"},wa={key:0,class:"flex items-center justify-center py-20"},ha={key:1,class:"text-center py-20"},xa={class:"text-muted-foreground"},ba={key:2},ya={key:0,class:"space-y-6"},ka=["onClick"],_a={class:"flex items-center gap-3"},Sa={class:"text-xl font-semibold text-foreground"},Da={class:"flex items-center gap-2"},Ua={key:0,class:"bg-red-600 text-white px-2 py-1 rounded-full text-sm font-medium"},Ca={key:0,class:"endpoint-group-content p-4"},za={key:0,class:"mb-4"},Wa={class:"grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"},Ha={key:1},ja={key:0,class:"text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3"},Ra={class:"grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"},Fa={key:1},Ta={key:0,class:"mb-6"},Ea={class:"grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"},qa={key:1},$a={key:0,class:"text-lg font-semibold text-foreground mb-3"},La={class:"grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"},Za={key:2,class:"mt-8 flex items-center justify-center gap-2"},Ma={class:"flex gap-1"},Aa={key:3,class:"mt-12 pb-8"},Na=96,Ia=50;var Ya={__name:"Home",props:{announcements:{type:Array,default:()=>[]}},emits:["showTooltip"],setup(e,{emit:t}){const s=e,a=(0,l.Fl)((()=>s.announcements?s.announcements.filter((e=>!e.archived)):[])),o=(0,l.Fl)((()=>s.announcements?s.announcements.filter((e=>e.archived)):[])),i=t,u=(0,r.iH)([]),d=(0,r.iH)([]),c=(0,r.iH)(!1),g=(0,r.iH)(1),m=(0,r.iH)(""),p=(0,r.iH)(!1),v=(0,r.iH)(!1),f=(0,r.iH)("false"!==localStorage.getItem("gatus:show-average-response-time")),w=(0,r.iH)(!1),h=(0,r.iH)(localStorage.getItem("gatus:sort-by")||"name"),b=(0,r.iH)(new Set),y=(0,l.Fl)((()=>{let e=[...u.value];if(m.value){const t=m.value.toLowerCase();e=e.filter((e=>e.name.toLowerCase().includes(t)||e.group&&e.group.toLowerCase().includes(t)))}return p.value&&(e=e.filter((e=>{if(!e.results||0===e.results.length)return!1;const t=e.results[e.results.length-1];return!t.success}))),v.value&&(e=e.filter((e=>!(!e.results||0===e.results.length)&&e.results.some((e=>!e.success))))),"health"===h.value&&e.sort(((e,t)=>{const s=e.results&&e.results.length>0&&e.results[e.results.length-1].success,a=t.results&&t.results.length>0&&t.results[t.results.length-1].success;return!s&&a?-1:s&&!a?1:e.name.localeCompare(t.name)})),e})),k=(0,l.Fl)((()=>{let e=[...d.value||[]];if(m.value){const t=m.value.toLowerCase();e=e.filter((e=>e.name.toLowerCase().includes(t)||e.group&&e.group.toLowerCase().includes(t)))}return p.value&&(e=e.filter((e=>!(!e.results||0===e.results.length)&&!e.results[e.results.length-1].success))),v.value&&(e=e.filter((e=>!(!e.results||0===e.results.length)&&e.results.some((e=>!e.success))))),"health"===h.value&&e.sort(((e,t)=>{const s=e.results&&e.results.length>0&&e.results[e.results.length-1].success,a=t.results&&t.results.length>0&&t.results[t.results.length-1].success;return!s&&a?-1:s&&!a?1:e.name.localeCompare(t.name)})),e})),_=(0,l.Fl)((()=>Math.ceil((y.value.length+k.value.length)/Na))),S=(0,l.Fl)((()=>{if(!w.value)return null;const e={};y.value.forEach((t=>{const s=t.group||"No Group";e[s]||(e[s]=[]),e[s].push(t)}));const t=Object.keys(e).sort(((e,t)=>"No Group"===e?1:"No Group"===t?-1:e.localeCompare(t))),s={};return t.forEach((t=>{s[t]=e[t]})),s})),D=(0,l.Fl)((()=>{if(!w.value)return null;const e={};y.value.forEach((t=>{const s=t.group||"No Group";e[s]||(e[s]={endpoints:[],suites:[]}),e[s].endpoints.push(t)})),k.value.forEach((t=>{const s=t.group||"No Group";e[s]||(e[s]={endpoints:[],suites:[]}),e[s].suites.push(t)}));const t=Object.keys(e).sort(((e,t)=>"No Group"===e?1:"No Group"===t?-1:e.localeCompare(t))),s={};return t.forEach((t=>{s[t]=e[t]})),s})),U=(0,l.Fl)((()=>{if(w.value)return S.value;const e=(g.value-1)*Na,t=e+Na;return y.value.slice(e,t)})),C=(0,l.Fl)((()=>{if(w.value)return k.value;const e=(g.value-1)*Na,t=e+Na;return k.value.slice(e,t)})),z=(0,l.Fl)((()=>{const e=[],t=5;let s=Math.max(1,g.value-Math.floor(t/2)),a=Math.min(_.value,s+t-1);a-s{const e=0===u.value.length&&0===d.value.length;e&&(c.value=!0);try{const t=await fetch(`/api/v1/endpoints/statuses?page=1&pageSize=${Ia}`,{credentials:"include"});if(200===t.status){const e=await t.json();u.value=e}else console.error("[Home][fetchData] Error fetching endpoints:",await t.text());const s=await fetch(`/api/v1/suites/statuses?page=1&pageSize=${Ia}`,{credentials:"include"});if(200===s.status){const e=await s.json();d.value=e||[]}else console.error("[Home][fetchData] Error fetching suites:",await s.text()),d.value||(d.value=[])}catch(t){console.error("[Home][fetchData] Error:",t)}finally{e&&(c.value=!1)}},H=()=>{u.value=[],d.value=[],W()},j=e=>{m.value=e,g.value=1},R=e=>{g.value=e,window.scrollTo({top:0,behavior:"smooth"})},F=()=>{f.value=!f.value,localStorage.setItem("gatus:show-average-response-time",f.value?"true":"false")},T=(e,t,s="hover")=>{i("showTooltip",e,t,s)},E=e=>e.filter((e=>{if(!e.results||0===e.results.length)return!1;const t=e.results[e.results.length-1];return!t.success})).length,q=e=>e.filter((e=>!(!e.results||0===e.results.length)&&!e.results[e.results.length-1].success)).length,$=e=>{b.value.has(e)?b.value.delete(e):b.value.add(e);const t=Array.from(b.value);localStorage.setItem("gatus:uncollapsed-groups",JSON.stringify(t)),localStorage.removeItem("gatus:collapsed-groups")},L=()=>{try{const e=localStorage.getItem("gatus:uncollapsed-groups");e&&(b.value=new Set(JSON.parse(e)))}catch(e){console.warn("Failed to parse saved uncollapsed groups:",e),localStorage.removeItem("gatus:uncollapsed-groups")}},Z=(0,l.Fl)((()=>window.config&&window.config.dashboardHeading&&"{{ .UI.DashboardHeading }}"!==window.config.dashboardHeading?window.config.dashboardHeading:"Health Dashboard")),M=(0,l.Fl)((()=>window.config&&window.config.dashboardSubheading&&"{{ .UI.DashboardSubheading }}"!==window.config.dashboardSubheading?window.config.dashboardSubheading:"Monitor the health of your endpoints in real-time"));return(0,l.bv)((()=>{W()})),(e,t)=>((0,l.wg)(),(0,l.iD)("div",da,[(0,l._)("div",ca,[(0,l._)("div",ga,[(0,l._)("div",ma,[(0,l._)("div",null,[(0,l._)("h1",pa,(0,n.zw)(Z.value),1),(0,l._)("p",va,(0,n.zw)(M.value),1)]),(0,l._)("div",fa,[(0,l.Wm)((0,r.SU)(x),{variant:"ghost",size:"icon",onClick:F,title:f.value?"Show min-max response time":"Show average response time"},{default:(0,l.w5)((()=>[f.value?((0,l.wg)(),(0,l.j4)((0,r.SU)(Ae.Z),{key:0,class:"h-5 w-5"})):((0,l.wg)(),(0,l.j4)((0,r.SU)(Ne.Z),{key:1,class:"h-5 w-5"}))])),_:1},8,["title"]),(0,l.Wm)((0,r.SU)(x),{variant:"ghost",size:"icon",onClick:H,title:"Refresh data"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(Ie.Z),{class:"h-5 w-5"})])),_:1})])]),(0,l.Wm)(Bs,{announcements:a.value},null,8,["announcements"]),(0,l.Wm)(rs,{onSearch:j,"onUpdate:showOnlyFailing":t[0]||(t[0]=e=>p.value=e),"onUpdate:showRecentFailures":t[1]||(t[1]=e=>v.value=e),"onUpdate:groupByGroup":t[2]||(t[2]=e=>w.value=e),"onUpdate:sortBy":t[3]||(t[3]=e=>h.value=e),onInitializeCollapsedGroups:L})]),c.value?((0,l.wg)(),(0,l.iD)("div",wa,[(0,l.Wm)(de,{size:"lg"})])):0===y.value.length&&0===k.value.length?((0,l.wg)(),(0,l.iD)("div",ha,[(0,l.Wm)((0,r.SU)(Ye.Z),{class:"h-12 w-12 text-muted-foreground mx-auto mb-4"}),t[6]||(t[6]=(0,l._)("h3",{class:"text-lg font-semibold mb-2"},"No endpoints or suites found",-1)),(0,l._)("p",xa,(0,n.zw)(m.value||p.value||v.value?"Try adjusting your filters":"No endpoints or suites are configured"),1)])):((0,l.wg)(),(0,l.iD)("div",ba,[w.value?((0,l.wg)(),(0,l.iD)("div",ya,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(D.value,((e,s)=>((0,l.wg)(),(0,l.iD)("div",{key:s,class:"endpoint-group border rounded-lg overflow-hidden"},[(0,l._)("div",{onClick:e=>$(s),class:"endpoint-group-header flex items-center justify-between p-4 bg-card border-b cursor-pointer hover:bg-accent/50 transition-colors"},[(0,l._)("div",_a,[b.value.has(s)?((0,l.wg)(),(0,l.j4)((0,r.SU)(Oe.Z),{key:0,class:"h-5 w-5 text-muted-foreground"})):((0,l.wg)(),(0,l.j4)((0,r.SU)(Pe.Z),{key:1,class:"h-5 w-5 text-muted-foreground"})),(0,l._)("h2",Sa,(0,n.zw)(s),1)]),(0,l._)("div",Da,[E(e.endpoints)+q(e.suites)>0?((0,l.wg)(),(0,l.iD)("span",Ua,(0,n.zw)(E(e.endpoints)+q(e.suites)),1)):((0,l.wg)(),(0,l.j4)((0,r.SU)(Ke.Z),{key:1,class:"h-6 w-6 text-green-600"}))])],8,ka),b.value.has(s)?((0,l.wg)(),(0,l.iD)("div",Ca,[e.suites.length>0?((0,l.wg)(),(0,l.iD)("div",za,[t[7]||(t[7]=(0,l._)("h3",{class:"text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3"},"Suites",-1)),(0,l._)("div",Wa,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.suites,(e=>((0,l.wg)(),(0,l.j4)(qt,{key:e.key,suite:e,maxResults:Ia,onShowTooltip:T},null,8,["suite"])))),128))])])):(0,l.kq)("",!0),e.endpoints.length>0?((0,l.wg)(),(0,l.iD)("div",Ha,[e.suites.length>0?((0,l.wg)(),(0,l.iD)("h3",ja,"Endpoints")):(0,l.kq)("",!0),(0,l._)("div",Ra,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.endpoints,(e=>((0,l.wg)(),(0,l.j4)(ht,{key:e.key,endpoint:e,maxResults:Ia,showAverageResponseTime:f.value,onShowTooltip:T},null,8,["endpoint","showAverageResponseTime"])))),128))])])):(0,l.kq)("",!0)])):(0,l.kq)("",!0)])))),128))])):((0,l.wg)(),(0,l.iD)("div",Fa,[k.value.length>0?((0,l.wg)(),(0,l.iD)("div",Ta,[t[8]||(t[8]=(0,l._)("h2",{class:"text-lg font-semibold text-foreground mb-3"},"Suites",-1)),(0,l._)("div",Ea,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(C.value,(e=>((0,l.wg)(),(0,l.j4)(qt,{key:e.key,suite:e,maxResults:Ia,onShowTooltip:T},null,8,["suite"])))),128))])])):(0,l.kq)("",!0),y.value.length>0?((0,l.wg)(),(0,l.iD)("div",qa,[k.value.length>0?((0,l.wg)(),(0,l.iD)("h2",$a,"Endpoints")):(0,l.kq)("",!0),(0,l._)("div",La,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(U.value,(e=>((0,l.wg)(),(0,l.j4)(ht,{key:e.key,endpoint:e,maxResults:Ia,showAverageResponseTime:f.value,onShowTooltip:T},null,8,["endpoint","showAverageResponseTime"])))),128))])])):(0,l.kq)("",!0)])),!w.value&&_.value>1?((0,l.wg)(),(0,l.iD)("div",Za,[(0,l.Wm)((0,r.SU)(x),{variant:"outline",size:"icon",disabled:1===g.value,onClick:t[4]||(t[4]=e=>R(g.value-1))},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(Ve.Z),{class:"h-4 w-4"})])),_:1},8,["disabled"]),(0,l._)("div",Ma,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(z.value,(e=>((0,l.wg)(),(0,l.j4)((0,r.SU)(x),{key:e,variant:e===g.value?"default":"outline",size:"sm",onClick:t=>R(e)},{default:(0,l.w5)((()=>[(0,l.Uk)((0,n.zw)(e),1)])),_:2},1032,["variant","onClick"])))),128))]),(0,l.Wm)((0,r.SU)(x),{variant:"outline",size:"icon",disabled:g.value===_.value,onClick:t[5]||(t[5]=e=>R(g.value+1))},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(Be.Z),{class:"h-4 w-4"})])),_:1},8,["disabled"])])):(0,l.kq)("",!0)])),o.value.length>0?((0,l.wg)(),(0,l.iD)("div",Aa,[(0,l.Wm)(ua,{announcements:o.value},null,8,["announcements"])])):(0,l.kq)("",!0)]),(0,l.Wm)(ys,{onRefreshData:W})]))}};const Oa=Ya;var Pa=Oa,Ka=s(318),Va=s(779),Ba=s(141),Ga=s(478);const Ja={class:"flex items-center justify-between"},Xa={class:"text-sm text-muted-foreground"};var Qa={__name:"Pagination",props:{numberOfResultsPerPage:Number,currentPageProp:{type:Number,default:1}},emits:["page"],setup(e,{emit:t}){const s=e,a=t,o=(0,r.iH)(s.currentPageProp),i=(0,l.Fl)((()=>{let e=100;if("undefined"!==typeof window&&window.config&&window.config.maximumNumberOfResults){const t=parseInt(window.config.maximumNumberOfResults);isNaN(t)||(e=t)}return Math.ceil(e/s.numberOfResultsPerPage)})),u=()=>{o.value--,a("page",o.value)},d=()=>{o.value++,a("page",o.value)};return(e,t)=>((0,l.wg)(),(0,l.iD)("div",Ja,[(0,l.Wm)((0,r.SU)(x),{variant:"outline",size:"sm",disabled:o.value>=i.value,onClick:d,class:"flex items-center gap-1"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(Ve.Z),{class:"h-4 w-4"}),t[0]||(t[0]=(0,l.Uk)(" Previous ",-1))])),_:1,__:[0]},8,["disabled"]),(0,l._)("span",Xa," Page "+(0,n.zw)(o.value)+" of "+(0,n.zw)(i.value),1),(0,l.Wm)((0,r.SU)(x),{variant:"outline",size:"sm",disabled:o.value<=1,onClick:u,class:"flex items-center gap-1"},{default:(0,l.w5)((()=>[t[1]||(t[1]=(0,l.Uk)(" Next ",-1)),(0,l.Wm)((0,r.SU)(Be.Z),{class:"h-4 w-4"})])),_:1,__:[1]},8,["disabled"])]))}};const el=Qa;var tl=el,sl=s(334),al=s(148),ll=s(282);s(210);const nl={class:"relative w-full",style:{height:"300px"}},rl={key:0,class:"absolute inset-0 flex items-center justify-center bg-background/50"},ol={key:1,class:"absolute inset-0 flex items-center justify-center text-muted-foreground"};var il={__name:"ResponseTimeChart",props:{endpointKey:{type:String,required:!0},duration:{type:String,required:!0,validator:e=>["24h","7d","30d"].includes(e)},serverUrl:{type:String,default:".."},events:{type:Array,default:()=>[]}},setup(e){al.kL.register(al.uw,al.f$,al.od,al.jn,al.Dx,al.u,al.De,al.Gu,al.FB,ll.Z);const t=e,s=(0,r.iH)(!0),a=(0,r.iH)(null),o=(0,r.iH)([]),i=(0,r.iH)([]),u=(0,r.iH)(document.documentElement.classList.contains("dark")),d=(0,r.iH)(null),c=()=>"rgba(239, 68, 68, 0.8)",g=(0,l.Fl)((()=>{if(!t.events||0===t.events.length)return[];const e=new Date;let s;switch(t.duration){case"24h":s=new Date(e.getTime()-864e5);break;case"7d":s=new Date(e.getTime()-6048e5);break;case"30d":s=new Date(e.getTime()-2592e6);break;default:return[]}const a=[];for(let l=0;le)continue;let o=null,i=!1;if(l+1{if(0===o.value.length)return{labels:[],datasets:[]};const e=o.value.map((e=>new Date(e)));return{labels:e,datasets:[{label:"Response Time (ms)",data:i.value,borderColor:u.value?"rgb(96, 165, 250)":"rgb(59, 130, 246)",backgroundColor:u.value?"rgba(96, 165, 250, 0.1)":"rgba(59, 130, 246, 0.1)",borderWidth:2,pointRadius:2,pointHoverRadius:4,tension:.1,fill:!0}]}})),p=(0,l.Fl)((()=>{d.value;const e=i.value.length>0?Math.max(...i.value):0,s=e/2;return{responsive:!0,maintainAspectRatio:!1,interaction:{mode:"index",intersect:!1},plugins:{legend:{display:!1},tooltip:{backgroundColor:u.value?"rgba(31, 41, 55, 0.95)":"rgba(255, 255, 255, 0.95)",titleColor:u.value?"#f9fafb":"#111827",bodyColor:u.value?"#d1d5db":"#374151",borderColor:u.value?"#4b5563":"#e5e7eb",borderWidth:1,padding:12,displayColors:!1,callbacks:{title:e=>{if(e.length>0){const t=new Date(e[0].parsed.x);return t.toLocaleString()}return""},label:e=>{const t=e.parsed.y;return`${t}ms`}}},annotation:{annotations:g.value.reduce(((e,t,a)=>{const l=new Date(t.timestamp).getTime();let n=0;if(o.value.length>0&&i.value.length>0){const e=o.value.reduce(((e,t,s)=>{const a=new Date(t).getTime(),n=Math.abs(a-l),r=Math.abs(new Date(o.value[e]).getTime()-l);return nd.value===a,content:[t.isOngoing?"Status: ONGOING":"Status: RESOLVED",`Unhealthy for ${t.duration}`,`Started at ${new Date(t.timestamp).toLocaleString()}`],backgroundColor:c(),color:"#ffffff",font:{size:11},padding:6,position:r}},e}),{})}},scales:{x:{type:"time",time:{unit:"24h"===t.duration?"hour":(t.duration,"day"),displayFormats:{hour:"MMM d, ha",day:"MMM d"}},grid:{color:u.value?"rgba(75, 85, 99, 0.3)":"rgba(229, 231, 235, 0.8)",drawBorder:!1},ticks:{color:u.value?"#9ca3af":"#6b7280",maxRotation:0,autoSkipPadding:20}},y:{beginAtZero:!0,grid:{color:u.value?"rgba(75, 85, 99, 0.3)":"rgba(229, 231, 235, 0.8)",drawBorder:!1},ticks:{color:u.value?"#9ca3af":"#6b7280",callback:e=>`${e}ms`}}}}})),v=async()=>{s.value=!0,a.value=null;try{const e=await fetch(`${t.serverUrl}/api/v1/endpoints/${t.endpointKey}/response-times/${t.duration}/history`,{credentials:"include"});if(200===e.status){const t=await e.json();o.value=t.timestamps||[],i.value=t.values||[]}else a.value="Failed to load chart data",console.error("[ResponseTimeChart] Error:",await e.text())}catch(e){a.value="Failed to load chart data",console.error("[ResponseTimeChart] Error:",e)}finally{s.value=!1}};return(0,l.YP)((()=>t.duration),(()=>{v()})),(0,l.bv)((()=>{v();const e=new MutationObserver((()=>{u.value=document.documentElement.classList.contains("dark")}));e.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}),(0,l.Ah)((()=>e.disconnect()))})),(e,t)=>((0,l.wg)(),(0,l.iD)("div",nl,[s.value?((0,l.wg)(),(0,l.iD)("div",rl,[(0,l.Wm)(de)])):a.value?((0,l.wg)(),(0,l.iD)("div",ol,(0,n.zw)(a.value),1)):((0,l.wg)(),(0,l.j4)((0,r.SU)(sl.x1),{key:2,data:m.value,options:p.value},null,8,["data","options"]))]))}};const ul=il;var dl=ul;const cl={class:"dashboard-container bg-background"},gl={class:"container mx-auto px-4 py-8 max-w-7xl"},ml={class:"mb-6"},pl={key:0,class:"space-y-6"},vl={class:"flex items-start justify-between"},fl={class:"text-4xl font-bold tracking-tight"},wl={class:"flex items-center gap-3 text-muted-foreground mt-2"},hl={key:0},xl={key:1},bl={key:2},yl={class:"grid gap-6 md:grid-cols-2 lg:grid-cols-4"},kl={class:"text-2xl font-bold"},_l={class:"text-2xl font-bold"},Sl={class:"text-2xl font-bold"},Dl={class:"text-2xl font-bold"},Ul={class:"flex items-center justify-between"},Cl={class:"flex items-center gap-2"},zl={class:"space-y-4"},Wl={key:1,class:"pt-4 border-t"},Hl={key:0,class:"space-y-6"},jl={class:"flex items-center justify-between"},Rl={class:"grid gap-4 md:grid-cols-2 lg:grid-cols-4"},Fl=["src","alt"],Tl={class:"grid gap-4 md:grid-cols-2 lg:grid-cols-4"},El={class:"text-sm text-muted-foreground mb-2"},ql=["src","alt"],$l={class:"text-center"},Ll=["src"],Zl={class:"space-y-4"},Ml={class:"mt-1"},Al={class:"flex-1"},Nl={class:"font-medium"},Il={class:"text-sm text-muted-foreground"},Yl={key:1,class:"flex items-center justify-center py-20"},Ol=50;var Pl={__name:"EndpointDetails",emits:["showTooltip"],setup(e,{emit:t}){const s=(0,i.tv)(),o=(0,i.yj)(),u=t,d=(0,r.iH)(null),c=(0,r.iH)(null),g=(0,r.iH)([]),m=(0,r.iH)(1),p=(0,r.iH)(!1),v=(0,r.iH)("false"!==localStorage.getItem("gatus:show-average-response-time")),f=(0,r.iH)("24h"),w=(0,r.iH)(!1),h=(0,l.Fl)((()=>c.value&&c.value.results&&0!==c.value.results.length?c.value.results[c.value.results.length-1]:null)),b=(0,l.Fl)((()=>h.value?h.value.success?"healthy":"unhealthy":"unknown")),y=(0,l.Fl)((()=>h.value?.hostname||null)),_=()=>{v.value=!v.value,localStorage.setItem("gatus:show-average-response-time",v.value?"true":"false")},S=(0,l.Fl)((()=>{if(!d.value||!d.value.results||0===d.value.results.length)return"N/A";let e=0,t=0;for(const s of d.value.results)s.duration&&(e+=s.duration,t++);return 0===t?"N/A":`${Math.round(e/t/1e6)}ms`})),U=(0,l.Fl)((()=>{if(!d.value||!d.value.results||0===d.value.results.length)return"N/A";let e=1/0,t=0,s=!1;for(const n of d.value.results){const a=n.duration;a&&(e=Math.min(e,a),t=Math.max(t,a),s=!0)}if(!s)return"N/A";const a=Math.trunc(e/1e6),l=Math.trunc(t/1e6);return a===l?`${a}ms`:`${a}-${l}ms`})),C=(0,l.Fl)((()=>c.value&&c.value.results&&0!==c.value.results.length?L(c.value.results[c.value.results.length-1].timestamp):"Never")),W=async()=>{w.value=!0;try{const e=await fetch(`/api/v1/endpoints/${o.params.key}/statuses?page=${m.value}&pageSize=${Ol}`,{credentials:"include"});if(200===e.status){const t=await e.json();d.value=t,1===m.value&&(c.value=t);let s=[];if(t.events&&t.events.length>0)for(let e=t.events.length-1;e>=0;e--){let a=t.events[e];if(e===t.events.length-1)"UNHEALTHY"===a.type?a.fancyText="Endpoint is unhealthy":"HEALTHY"===a.type?a.fancyText="Endpoint is healthy":"START"===a.type&&(a.fancyText="Monitoring started");else{let s=t.events[e+1];"HEALTHY"===a.type?a.fancyText="Endpoint became healthy":"UNHEALTHY"===a.type?a.fancyText=s?"Endpoint was unhealthy for "+Z(s.timestamp,a.timestamp):"Endpoint became unhealthy":"START"===a.type&&(a.fancyText="Monitoring started")}a.fancyTimeAgo=L(a.timestamp),s.push(a)}if(g.value=s,t.results&&t.results.length>0)for(let e=0;e0){p.value=!0;break}}else console.error("[Details][fetchData] Error:",await e.text())}catch(e){console.error("[Details][fetchData] Error:",e)}finally{w.value=!1}},H=()=>{s.push("/")},R=e=>{m.value=e,W()},F=(e,t,s="hover")=>{u("showTooltip",e,t,s)},T=e=>new Date(e).toLocaleString(),E=()=>`/api/v1/endpoints/${d.value.key}/health/badge.svg`,q=e=>`/api/v1/endpoints/${d.value.key}/uptimes/${e}/badge.svg`,$=e=>`/api/v1/endpoints/${d.value.key}/response-times/${e}/badge.svg`;return(0,l.bv)((()=>{W()})),(e,t)=>((0,l.wg)(),(0,l.iD)("div",cl,[(0,l._)("div",gl,[(0,l._)("div",ml,[(0,l.Wm)((0,r.SU)(x),{variant:"ghost",class:"mb-4",onClick:H},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(Ka.Z),{class:"h-4 w-4 mr-2"}),t[1]||(t[1]=(0,l.Uk)(" Back to Dashboard ",-1))])),_:1,__:[1]}),d.value&&d.value.name?((0,l.wg)(),(0,l.iD)("div",pl,[(0,l._)("div",vl,[(0,l._)("div",null,[(0,l._)("h1",fl,(0,n.zw)(d.value.name),1),(0,l._)("div",wl,[d.value.group?((0,l.wg)(),(0,l.iD)("span",hl,"Group: "+(0,n.zw)(d.value.group),1)):(0,l.kq)("",!0),d.value.group&&y.value?((0,l.wg)(),(0,l.iD)("span",xl,"•")):(0,l.kq)("",!0),y.value?((0,l.wg)(),(0,l.iD)("span",bl,(0,n.zw)(y.value),1)):(0,l.kq)("",!0)])]),(0,l.Wm)(tt,{status:b.value},null,8,["status"])]),(0,l._)("div",yl,[(0,l.Wm)((0,r.SU)(k),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),{class:"pb-2"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),{class:"text-sm font-medium text-muted-foreground"},{default:(0,l.w5)((()=>t[2]||(t[2]=[(0,l.Uk)("Current Status",-1)]))),_:1,__:[2]})])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("div",kl,(0,n.zw)("healthy"===b.value?"Operational":"Issues Detected"),1)])),_:1})])),_:1}),(0,l.Wm)((0,r.SU)(k),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),{class:"pb-2"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),{class:"text-sm font-medium text-muted-foreground"},{default:(0,l.w5)((()=>t[3]||(t[3]=[(0,l.Uk)("Avg Response Time",-1)]))),_:1,__:[3]})])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("div",_l,(0,n.zw)(S.value),1)])),_:1})])),_:1}),(0,l.Wm)((0,r.SU)(k),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),{class:"pb-2"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),{class:"text-sm font-medium text-muted-foreground"},{default:(0,l.w5)((()=>t[4]||(t[4]=[(0,l.Uk)("Response Time Range",-1)]))),_:1,__:[4]})])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("div",Sl,(0,n.zw)(U.value),1)])),_:1})])),_:1}),(0,l.Wm)((0,r.SU)(k),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),{class:"pb-2"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),{class:"text-sm font-medium text-muted-foreground"},{default:(0,l.w5)((()=>t[5]||(t[5]=[(0,l.Uk)("Last Check",-1)]))),_:1,__:[5]})])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("div",Dl,(0,n.zw)(C.value),1)])),_:1})])),_:1})]),(0,l.Wm)((0,r.SU)(k),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),null,{default:(0,l.w5)((()=>[(0,l._)("div",Ul,[(0,l.Wm)((0,r.SU)(z),null,{default:(0,l.w5)((()=>t[6]||(t[6]=[(0,l.Uk)("Recent Checks",-1)]))),_:1,__:[6]}),(0,l._)("div",Cl,[(0,l.Wm)((0,r.SU)(x),{variant:"ghost",size:"icon",onClick:_,title:v.value?"Show min-max response time":"Show average response time"},{default:(0,l.w5)((()=>[v.value?((0,l.wg)(),(0,l.j4)((0,r.SU)(Ae.Z),{key:0,class:"h-5 w-5"})):((0,l.wg)(),(0,l.j4)((0,r.SU)(Ne.Z),{key:1,class:"h-5 w-5"}))])),_:1},8,["title"]),(0,l.Wm)((0,r.SU)(x),{variant:"ghost",size:"icon",onClick:W,title:"Refresh data",disabled:w.value},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(Ie.Z),{class:(0,n.C_)(["h-4 w-4",w.value&&"animate-spin"])},null,8,["class"])])),_:1},8,["disabled"])])])])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("div",zl,[d.value?((0,l.wg)(),(0,l.j4)(ht,{key:0,endpoint:d.value,maxResults:Ol,showAverageResponseTime:v.value,onShowTooltip:F,class:"border-0 shadow-none bg-transparent p-0"},null,8,["endpoint","showAverageResponseTime"])):(0,l.kq)("",!0),d.value&&d.value.key?((0,l.wg)(),(0,l.iD)("div",Wl,[(0,l.Wm)(tl,{onPage:R,numberOfResultsPerPage:Ol,currentPageProp:m.value},null,8,["currentPageProp"])])):(0,l.kq)("",!0)])])),_:1})])),_:1}),p.value?((0,l.wg)(),(0,l.iD)("div",Hl,[(0,l.Wm)((0,r.SU)(k),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),null,{default:(0,l.w5)((()=>[(0,l._)("div",jl,[(0,l.Wm)((0,r.SU)(z),null,{default:(0,l.w5)((()=>t[7]||(t[7]=[(0,l.Uk)("Response Time Trend",-1)]))),_:1,__:[7]}),(0,l.wy)((0,l._)("select",{"onUpdate:modelValue":t[0]||(t[0]=e=>f.value=e),class:"text-sm bg-background border rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-ring"},t[8]||(t[8]=[(0,l._)("option",{value:"24h"},"24 hours",-1),(0,l._)("option",{value:"7d"},"7 days",-1),(0,l._)("option",{value:"30d"},"30 days",-1)]),512),[[a.bM,f.value]])])])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[d.value&&d.value.key?((0,l.wg)(),(0,l.j4)(dl,{key:0,endpointKey:d.value.key,duration:f.value,serverUrl:e.serverUrl,events:d.value.events||[]},null,8,["endpointKey","duration","serverUrl","events"])):(0,l.kq)("",!0)])),_:1})])),_:1}),(0,l._)("div",Rl,[((0,l.wg)(),(0,l.iD)(l.HY,null,(0,l.Ko)(["30d","7d","24h","1h"],(e=>(0,l.Wm)((0,r.SU)(k),{key:e},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),{class:"pb-2"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),{class:"text-sm font-medium text-muted-foreground text-center"},{default:(0,l.w5)((()=>[(0,l.Uk)((0,n.zw)("30d"===e?"Last 30 days":"7d"===e?"Last 7 days":"24h"===e?"Last 24 hours":"Last hour"),1)])),_:2},1024)])),_:2},1024),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("img",{src:$(e),alt:`${e} response time`,class:"mx-auto mt-2"},null,8,Fl)])),_:2},1024)])),_:2},1024))),64))])])):(0,l.kq)("",!0),(0,l.Wm)((0,r.SU)(k),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),null,{default:(0,l.w5)((()=>t[9]||(t[9]=[(0,l.Uk)("Uptime Statistics",-1)]))),_:1,__:[9]})])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("div",Tl,[((0,l.wg)(),(0,l.iD)(l.HY,null,(0,l.Ko)(["30d","7d","24h","1h"],(e=>(0,l._)("div",{key:e,class:"text-center"},[(0,l._)("p",El,(0,n.zw)("30d"===e?"Last 30 days":"7d"===e?"Last 7 days":"24h"===e?"Last 24 hours":"Last hour"),1),(0,l._)("img",{src:q(e),alt:`${e} uptime`,class:"mx-auto"},null,8,ql)]))),64))])])),_:1})])),_:1}),(0,l.Wm)((0,r.SU)(k),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),null,{default:(0,l.w5)((()=>t[10]||(t[10]=[(0,l.Uk)("Current Health",-1)]))),_:1,__:[10]})])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("div",$l,[(0,l._)("img",{src:E(),alt:"health badge",class:"mx-auto"},null,8,Ll)])])),_:1})])),_:1}),g.value&&g.value.length>0?((0,l.wg)(),(0,l.j4)((0,r.SU)(k),{key:1},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),null,{default:(0,l.w5)((()=>t[11]||(t[11]=[(0,l.Uk)("Events",-1)]))),_:1,__:[11]})])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("div",Zl,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(g.value,(e=>((0,l.wg)(),(0,l.iD)("div",{key:e.timestamp,class:"flex items-start gap-4 pb-4 border-b last:border-0"},[(0,l._)("div",Ml,["HEALTHY"===e.type?((0,l.wg)(),(0,l.j4)((0,r.SU)(Va.Z),{key:0,class:"h-5 w-5 text-green-500"})):"UNHEALTHY"===e.type?((0,l.wg)(),(0,l.j4)((0,r.SU)(Ba.Z),{key:1,class:"h-5 w-5 text-red-500"})):((0,l.wg)(),(0,l.j4)((0,r.SU)(Ga.Z),{key:2,class:"h-5 w-5 text-muted-foreground"}))]),(0,l._)("div",Al,[(0,l._)("p",Nl,(0,n.zw)(e.fancyText),1),(0,l._)("p",Il,(0,n.zw)(T(e.timestamp))+" • "+(0,n.zw)(e.fancyTimeAgo),1)])])))),128))])])),_:1})])),_:1})):(0,l.kq)("",!0)])):((0,l.wg)(),(0,l.iD)("div",Yl,[(0,l.Wm)(de,{size:"lg"})]))])]),(0,l.Wm)(ys,{onRefreshData:W})]))}};const Kl=Pl;var Vl=Kl,Bl=s(469),Gl=s(399),Jl=s(167);const Xl=e=>{if(!e&&0!==e)return"N/A";const t=e/1e6;return t<1e3?`${Math.trunc(t)}ms`:`${(t/1e3).toFixed(2)}s`},Ql={class:"relative flex-shrink-0"},en={class:"flex-1 min-w-0 pt-1"},tn={class:"flex items-center justify-between gap-2 mb-1"},sn={class:"font-medium text-sm truncate"},an={class:"text-xs text-muted-foreground whitespace-nowrap"},ln={class:"flex flex-wrap gap-1"},nn={key:0,class:"inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-md"},rn={key:1,class:"inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 rounded-md"};var on={__name:"FlowStep",props:{step:{type:Object,required:!0},index:{type:Number,required:!0},isLast:{type:Boolean,default:!1},previousStep:{type:Object,default:null}},emits:["step-click"],setup(e){const t=e,s=(0,l.Fl)((()=>{switch(t.step.status){case"success":return Ke.Z;case"failed":return ks.Z;case"skipped":return Bl.Z;case"not-started":return Jl.Z;default:return Jl.Z}})),a=(0,l.Fl)((()=>{const e="border-2";if(t.step.isAlwaysRun)switch(t.step.status){case"success":return`${e} bg-green-500 text-white border-green-600 ring-2 ring-blue-200 dark:ring-blue-800`;case"failed":return`${e} bg-red-500 text-white border-red-600 ring-2 ring-blue-200 dark:ring-blue-800`;default:return`${e} bg-blue-500 text-white border-blue-600 ring-2 ring-blue-200 dark:ring-blue-800`}switch(t.step.status){case"success":return`${e} bg-green-500 text-white border-green-600`;case"failed":return`${e} bg-red-500 text-white border-red-600`;case"skipped":return`${e} bg-gray-400 text-white border-gray-500`;case"not-started":return`${e} bg-gray-200 text-gray-500 border-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600`;default:return`${e} bg-gray-200 text-gray-500 border-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600`}})),o=(0,l.Fl)((()=>{if(!t.previousStep)return"bg-gray-300 dark:bg-gray-600";if("skipped"===t.step.status)return"border-l-2 border-dashed border-gray-400 bg-transparent";switch(t.previousStep.status){case"success":return"bg-green-500";case"failed":return"bg-red-500";default:return"bg-gray-300 dark:bg-gray-600"}})),i=(0,l.Fl)((()=>{const e=t.step.nextStepStatus;switch(t.step.status){case"success":return"skipped"===e?"bg-gray-300 dark:bg-gray-600":"bg-green-500";case"failed":return"skipped"===e?"border-l-2 border-dashed border-gray-400 bg-transparent":"bg-red-500";default:return"bg-gray-300 dark:bg-gray-600"}}));return(t,u)=>((0,l.wg)(),(0,l.iD)("div",{class:"flex items-start gap-4 relative group hover:bg-accent/30 rounded-lg p-2 -m-2 transition-colors cursor-pointer",onClick:u[0]||(u[0]=e=>t.$emit("step-click"))},[(0,l._)("div",Ql,[e.index>0?((0,l.wg)(),(0,l.iD)("div",{key:0,class:(0,n.C_)([o.value,"absolute left-1/2 bottom-8 w-0.5 h-4 -translate-x-px"])},null,2)):(0,l.kq)("",!0),(0,l._)("div",{class:(0,n.C_)([a.value,"w-8 h-8 rounded-full flex items-center justify-center"])},[((0,l.wg)(),(0,l.j4)((0,l.LL)(s.value),{class:"w-4 h-4"}))],2),e.isLast?(0,l.kq)("",!0):((0,l.wg)(),(0,l.iD)("div",{key:1,class:(0,n.C_)([i.value,"absolute left-1/2 top-8 w-0.5 h-4 -translate-x-px"])},null,2))]),(0,l._)("div",en,[(0,l._)("div",tn,[(0,l._)("h4",sn,(0,n.zw)(e.step.name),1),(0,l._)("span",an,(0,n.zw)((0,r.SU)(Xl)(e.step.duration)),1)]),(0,l._)("div",ln,[e.step.isAlwaysRun?((0,l.wg)(),(0,l.iD)("span",nn,[(0,l.Wm)((0,r.SU)(Gl.Z),{class:"w-3 h-3"}),u[1]||(u[1]=(0,l.Uk)(" Always Run ",-1))])):(0,l.kq)("",!0),e.step.errors?.length?((0,l.wg)(),(0,l.iD)("span",rn,(0,n.zw)(e.step.errors.length)+" error"+(0,n.zw)(1!==e.step.errors.length?"s":""),1)):(0,l.kq)("",!0)])])]))}};const un=on;var dn=un;const cn={class:"space-y-4"},gn={class:"flex items-center gap-4"},mn={class:"flex-1 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"},pn={class:"flex items-center justify-between text-xs text-muted-foreground"},vn={key:0},fn={class:"space-y-2"},wn={class:"mt-6 pt-4 border-t"},hn={class:"grid grid-cols-2 md:grid-cols-4 gap-3 text-xs"},xn={key:0,class:"flex items-center gap-2"},bn={class:"w-4 h-4 rounded-full bg-green-500 flex items-center justify-center"},yn={key:1,class:"flex items-center gap-2"},kn={class:"w-4 h-4 rounded-full bg-red-500 flex items-center justify-center"},_n={key:2,class:"flex items-center gap-2"},Sn={class:"w-4 h-4 rounded-full bg-gray-400 flex items-center justify-center"},Dn={key:3,class:"flex items-center gap-2"},Un={class:"w-4 h-4 rounded-full bg-blue-500 border-2 border-blue-200 dark:border-blue-800 flex items-center justify-center"};var Cn={__name:"SequentialFlowDiagram",props:{flowSteps:{type:Array,default:()=>[]},progressPercentage:{type:Number,default:0},completedSteps:{type:Number,default:0},totalSteps:{type:Number,default:0}},emits:["step-selected"],setup(e){const t=e,s=(0,l.Fl)((()=>t.completedSteps)),a=(0,l.Fl)((()=>t.totalSteps)),o=(0,l.Fl)((()=>t.flowSteps.reduce(((e,t)=>e+(t.duration||0)),0))),i=(0,l.Fl)((()=>t.flowSteps.some((e=>"success"===e.status)))),u=(0,l.Fl)((()=>t.flowSteps.some((e=>"failed"===e.status)))),d=(0,l.Fl)((()=>t.flowSteps.some((e=>"skipped"===e.status)))),c=(0,l.Fl)((()=>t.flowSteps.some((e=>!0===e.isAlwaysRun))));return(t,g)=>((0,l.wg)(),(0,l.iD)("div",cn,[(0,l._)("div",gn,[g[0]||(g[0]=(0,l._)("div",{class:"text-sm font-medium text-muted-foreground"},"Start",-1)),(0,l._)("div",mn,[(0,l._)("div",{class:"h-full bg-green-500 dark:bg-green-600 rounded-full transition-all duration-300 ease-out",style:(0,n.j5)({width:e.progressPercentage+"%"})},null,4)]),g[1]||(g[1]=(0,l._)("div",{class:"text-sm font-medium text-muted-foreground"},"End",-1))]),(0,l._)("div",pn,[(0,l._)("span",null,(0,n.zw)(s.value)+"/"+(0,n.zw)(a.value)+" steps successful",1),o.value>0?((0,l.wg)(),(0,l.iD)("span",vn,(0,n.zw)((0,r.SU)(Xl)(o.value))+" total",1)):(0,l.kq)("",!0)]),(0,l._)("div",fn,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.flowSteps,((s,a)=>((0,l.wg)(),(0,l.j4)(dn,{key:a,step:s,index:a,"is-last":a===e.flowSteps.length-1,"previous-step":a>0?e.flowSteps[a-1]:null,onStepClick:e=>t.$emit("step-selected",s,a)},null,8,["step","index","is-last","previous-step","onStepClick"])))),128))]),(0,l._)("div",wn,[g[6]||(g[6]=(0,l._)("div",{class:"text-sm font-medium text-muted-foreground mb-2"},"Status Legend",-1)),(0,l._)("div",hn,[i.value?((0,l.wg)(),(0,l.iD)("div",xn,[(0,l._)("div",bn,[(0,l.Wm)((0,r.SU)(Ke.Z),{class:"w-3 h-3 text-white"})]),g[2]||(g[2]=(0,l._)("span",{class:"text-muted-foreground"},"Success",-1))])):(0,l.kq)("",!0),u.value?((0,l.wg)(),(0,l.iD)("div",yn,[(0,l._)("div",kn,[(0,l.Wm)((0,r.SU)(ks.Z),{class:"w-3 h-3 text-white"})]),g[3]||(g[3]=(0,l._)("span",{class:"text-muted-foreground"},"Failed",-1))])):(0,l.kq)("",!0),d.value?((0,l.wg)(),(0,l.iD)("div",_n,[(0,l._)("div",Sn,[(0,l.Wm)((0,r.SU)(Bl.Z),{class:"w-3 h-3 text-white"})]),g[4]||(g[4]=(0,l._)("span",{class:"text-muted-foreground"},"Skipped",-1))])):(0,l.kq)("",!0),c.value?((0,l.wg)(),(0,l.iD)("div",Dn,[(0,l._)("div",Un,[(0,l.Wm)((0,r.SU)(Gl.Z),{class:"w-3 h-3 text-white"})]),g[5]||(g[5]=(0,l._)("span",{class:"text-muted-foreground"},"Always Run",-1))])):(0,l.kq)("",!0)])])]))}};const zn=Cn;var Wn=zn,Hn=s(293),jn=s(322),Rn=s(740);const Fn={class:"flex items-center justify-between p-4 border-b"},Tn={class:"text-lg font-semibold flex items-center gap-2"},En={class:"text-sm text-muted-foreground mt-1"},qn={class:"p-4 space-y-4 overflow-y-auto max-h-[60vh]"},$n={key:0,class:"flex flex-wrap gap-2"},Ln={class:"flex items-center gap-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-700"},Zn={key:1,class:"space-y-2"},Mn={class:"text-sm font-medium flex items-center gap-2 text-red-600 dark:text-red-400"},An={class:"space-y-2"},Nn={key:2,class:"space-y-2"},In={class:"text-sm font-medium flex items-center gap-2"},Yn={class:"text-xs font-mono text-muted-foreground"},On={key:3,class:"space-y-2"},Pn={class:"text-sm font-medium flex items-center gap-2"},Kn={class:"grid grid-cols-2 gap-4 text-xs"},Vn={class:"font-mono mt-1"},Bn={key:4,class:"space-y-2"},Gn={class:"text-sm font-medium flex items-center gap-2"},Jn={class:"space-y-2 max-h-48 overflow-y-auto"},Xn={class:"flex-shrink-0 mt-0.5"},Qn={class:"flex-1 min-w-0 flex items-center justify-between gap-3"},er={key:5,class:"space-y-2"},tr={class:"text-sm font-medium flex items-center gap-2"},sr={class:"space-y-3 text-xs"},ar={key:0},lr={class:"font-mono mt-1 break-all"},nr={key:1},rr={class:"mt-1 font-medium"},or={key:2},ir={class:"mt-1"},ur={key:3},dr={class:"mt-1"},cr={key:6,class:"space-y-2"},gr={class:"text-sm font-medium flex items-center gap-2 text-red-600 dark:text-red-400"},mr={class:"space-y-2 max-h-32 overflow-y-auto"};var pr={__name:"StepDetailsModal",props:{step:{type:Object,required:!0},index:{type:Number,required:!0}},emits:["close"],setup(e){const t=e,s=(0,l.Fl)((()=>{switch(t.step.status){case"success":return Ke.Z;case"failed":return ks.Z;case"skipped":return Bl.Z;case"not-started":return Jl.Z;default:return Jl.Z}})),o=(0,l.Fl)((()=>{switch(t.step.status){case"success":return"text-green-600 dark:text-green-400";case"failed":return"text-red-600 dark:text-red-400";case"skipped":return"text-gray-600 dark:text-gray-400";default:return"text-blue-600 dark:text-blue-400"}}));return(t,i)=>((0,l.wg)(),(0,l.iD)("div",{class:"fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50",onClick:i[2]||(i[2]=e=>t.$emit("close"))},[(0,l._)("div",{class:"bg-background border rounded-lg shadow-lg max-w-2xl w-full max-h-[80vh] overflow-hidden",onClick:i[1]||(i[1]=(0,a.iM)((()=>{}),["stop"]))},[(0,l._)("div",Fn,[(0,l._)("div",null,[(0,l._)("h2",Tn,[((0,l.wg)(),(0,l.j4)((0,l.LL)(s.value),{class:(0,n.C_)([o.value,"w-5 h-5"])},null,8,["class"])),(0,l.Uk)(" "+(0,n.zw)(e.step.name),1)]),(0,l._)("p",En," Step "+(0,n.zw)(e.index+1)+" • "+(0,n.zw)((0,r.SU)(Xl)(e.step.duration)),1)]),(0,l.Wm)((0,r.SU)(x),{variant:"ghost",size:"icon",onClick:i[0]||(i[0]=e=>t.$emit("close"))},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(d.Z),{class:"w-4 h-4"})])),_:1})]),(0,l._)("div",qn,[e.step.isAlwaysRun?((0,l.wg)(),(0,l.iD)("div",$n,[(0,l._)("div",Ln,[(0,l.Wm)((0,r.SU)(Gl.Z),{class:"w-4 h-4 text-blue-600 dark:text-blue-400"}),i[3]||(i[3]=(0,l._)("div",null,[(0,l._)("p",{class:"text-sm font-medium text-blue-900 dark:text-blue-200"},"Always Run"),(0,l._)("p",{class:"text-xs text-blue-600 dark:text-blue-400"},"This endpoint is configured to execute even after failures")],-1))])])):(0,l.kq)("",!0),e.step.errors?.length?((0,l.wg)(),(0,l.iD)("div",Zn,[(0,l._)("h3",Mn,[(0,l.Wm)((0,r.SU)(Ye.Z),{class:"w-4 h-4"}),(0,l.Uk)(" Errors ("+(0,n.zw)(e.step.errors.length)+") ",1)]),(0,l._)("div",An,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.step.errors,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:"p-3 bg-red-50 dark:bg-red-900/50 border border-red-200 dark:border-red-700 rounded text-sm font-mono text-red-800 dark:text-red-300 break-all"},(0,n.zw)(e),1)))),128))])])):(0,l.kq)("",!0),e.step.result&&e.step.result.timestamp?((0,l.wg)(),(0,l.iD)("div",Nn,[(0,l._)("h3",In,[(0,l.Wm)((0,r.SU)(Hn.Z),{class:"w-4 h-4"}),i[4]||(i[4]=(0,l.Uk)(" Timestamp ",-1))]),(0,l._)("p",Yn,(0,n.zw)((0,r.SU)(M)(e.step.result.timestamp)),1)])):(0,l.kq)("",!0),e.step.result?((0,l.wg)(),(0,l.iD)("div",On,[(0,l._)("h3",Pn,[(0,l.Wm)((0,r.SU)(jn.Z),{class:"w-4 h-4"}),i[5]||(i[5]=(0,l.Uk)(" Response ",-1))]),(0,l._)("div",Kn,[(0,l._)("div",null,[i[6]||(i[6]=(0,l._)("span",{class:"text-muted-foreground"},"Duration:",-1)),(0,l._)("p",Vn,(0,n.zw)((0,r.SU)(Xl)(e.step.result.duration)),1)]),(0,l._)("div",null,[i[7]||(i[7]=(0,l._)("span",{class:"text-muted-foreground"},"Success:",-1)),(0,l._)("p",{class:(0,n.C_)(["mt-1",e.step.result.success?"text-green-600 dark:text-green-400":"text-red-600 dark:text-red-400"])},(0,n.zw)(e.step.result.success?"Yes":"No"),3)])])])):(0,l.kq)("",!0),e.step.result?.conditionResults?.length?((0,l.wg)(),(0,l.iD)("div",Bn,[(0,l._)("h3",Gn,[(0,l.Wm)((0,r.SU)(Ke.Z),{class:"w-4 h-4"}),(0,l.Uk)(" Condition Results ("+(0,n.zw)(e.step.result.conditionResults.length)+") ",1)]),(0,l._)("div",Jn,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.step.result.conditionResults,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:(0,n.C_)(["flex items-start gap-3 p-1 rounded-lg border",e.success?"bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-700":"bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-700"])},[(0,l._)("div",Xn,[e.success?((0,l.wg)(),(0,l.j4)((0,r.SU)(Ke.Z),{key:0,class:"w-4 h-4 text-green-600 dark:text-green-400"})):((0,l.wg)(),(0,l.j4)((0,r.SU)(ks.Z),{key:1,class:"w-4 h-4 text-red-600 dark:text-red-400"}))]),(0,l._)("div",Qn,[(0,l._)("p",{class:(0,n.C_)(["text-sm font-mono break-all",e.success?"text-green-800 dark:text-green-200":"text-red-800 dark:text-red-200"])},(0,n.zw)(e.condition),3),(0,l._)("span",{class:(0,n.C_)(["text-xs font-medium whitespace-nowrap",e.success?"text-green-600 dark:text-green-400":"text-red-600 dark:text-red-400"])},(0,n.zw)(e.success?"Passed":"Failed"),3)])],2)))),128))])])):(0,l.kq)("",!0),e.step.endpoint?((0,l.wg)(),(0,l.iD)("div",er,[(0,l._)("h3",tr,[(0,l.Wm)((0,r.SU)(Rn.Z),{class:"w-4 h-4"}),i[8]||(i[8]=(0,l.Uk)(" Endpoint Configuration ",-1))]),(0,l._)("div",sr,[e.step.endpoint.url?((0,l.wg)(),(0,l.iD)("div",ar,[i[9]||(i[9]=(0,l._)("span",{class:"text-muted-foreground"},"URL:",-1)),(0,l._)("p",lr,(0,n.zw)(e.step.endpoint.url),1)])):(0,l.kq)("",!0),e.step.endpoint.method?((0,l.wg)(),(0,l.iD)("div",nr,[i[10]||(i[10]=(0,l._)("span",{class:"text-muted-foreground"},"Method:",-1)),(0,l._)("p",rr,(0,n.zw)(e.step.endpoint.method),1)])):(0,l.kq)("",!0),e.step.endpoint.interval?((0,l.wg)(),(0,l.iD)("div",or,[i[11]||(i[11]=(0,l._)("span",{class:"text-muted-foreground"},"Interval:",-1)),(0,l._)("p",ir,(0,n.zw)(e.step.endpoint.interval),1)])):(0,l.kq)("",!0),e.step.endpoint.timeout?((0,l.wg)(),(0,l.iD)("div",ur,[i[12]||(i[12]=(0,l._)("span",{class:"text-muted-foreground"},"Timeout:",-1)),(0,l._)("p",dr,(0,n.zw)(e.step.endpoint.timeout),1)])):(0,l.kq)("",!0)])])):(0,l.kq)("",!0),e.step.result?.errors?.length?((0,l.wg)(),(0,l.iD)("div",cr,[(0,l._)("h3",gr,[(0,l.Wm)((0,r.SU)(Ye.Z),{class:"w-4 h-4"}),(0,l.Uk)(" Result Errors ("+(0,n.zw)(e.step.result.errors.length)+") ",1)]),(0,l._)("div",mr,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(e.step.result.errors,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:"p-3 bg-red-50 dark:bg-red-900/50 border border-red-200 dark:border-red-700 rounded text-sm font-mono text-red-800 dark:text-red-300 break-all"},(0,n.zw)(e),1)))),128))])])):(0,l.kq)("",!0)])])]))}};const vr=pr;var fr=vr;const wr={class:"suite-details-container bg-background min-h-screen"},hr={class:"container mx-auto px-4 py-8 max-w-7xl"},xr={class:"mb-6"},br={class:"flex items-start justify-between"},yr={class:"text-3xl font-bold tracking-tight"},kr={class:"text-muted-foreground mt-2"},_r={key:0},Sr={key:1},Dr={class:"flex items-center gap-2"},Ur={key:0,class:"flex items-center justify-center py-20"},Cr={key:1,class:"text-center py-20"},zr={key:2,class:"space-y-6"},Wr={class:"space-y-4"},Hr={class:"grid grid-cols-2 md:grid-cols-4 gap-4"},jr={class:"text-lg font-medium"},Rr={class:"text-lg font-medium"},Fr={class:"text-lg font-medium"},Tr={class:"text-lg font-medium"},Er={class:"mt-6"},qr={key:0,class:"mt-6"},$r={class:"space-y-2"},Lr={key:0,class:"space-y-2"},Zr=["onClick"],Mr={class:"flex items-center gap-3"},Ar={class:"text-sm font-medium"},Nr={class:"text-xs text-muted-foreground"},Ir={key:1,class:"text-center py-8 text-muted-foreground"};var Yr={__name:"SuiteDetails",setup(e){const t=(0,i.tv)(),s=(0,i.yj)(),a=(0,r.iH)(!1),o=(0,r.iH)(null),u=(0,r.iH)(null),d=(0,r.iH)(null),c=(0,r.iH)(0),g=(0,l.Fl)((()=>o.value&&o.value.results&&0!==o.value.results.length?[...o.value.results].sort(((e,t)=>new Date(t.timestamp)-new Date(e.timestamp))):[])),m=(0,l.Fl)((()=>o.value&&o.value.results&&0!==o.value.results.length?u.value||g.value[0]:null)),p=async()=>{const e=!o.value;e&&(a.value=!0);try{const t=await fetch(`/api/v1/suites/${s.params.key}/statuses`,{credentials:"include"});if(200===t.status){const e=await t.json(),s=o.value;if(o.value=e,e.results&&e.results.length>0){const t=[...e.results].sort(((e,t)=>new Date(t.timestamp)-new Date(e.timestamp))),a=!u.value||s?.results&&u.value.timestamp===[...s.results].sort(((e,t)=>new Date(t.timestamp)-new Date(e.timestamp)))[0]?.timestamp;a&&(u.value=t[0])}}else 404===t.status?o.value=null:console.error("[SuiteDetails][fetchData] Error:",await t.text())}catch(t){console.error("[SuiteDetails][fetchData] Error:",t)}finally{e&&(a.value=!1)}},v=()=>{p()},f=()=>{t.push("/")},w=e=>L(e),h=e=>{const t=new Date(e);return t.toLocaleString()},b=e=>{if(!e||!e.endpointResults||0===e.endpointResults.length)return 0;const t=e.endpointResults.filter((e=>e.success)).length;return Math.round(t/e.endpointResults.length*100)},y=(0,l.Fl)((()=>{if(!m.value||!m.value.endpointResults)return[];const e=m.value.endpointResults;return e.map(((t,s)=>{const a=o.value?.endpoints?.[s],l=e[s+1];let n=!1;for(let r=0;ry.value.filter((e=>"success"===e.status)).length)),S=(0,l.Fl)((()=>y.value.length?Math.round(_.value/y.value.length*100):0)),U=e=>e?e.conditionResults&&e.conditionResults.some((e=>e.condition.includes("SKIP")))?"skipped":e.success?"success":"failed":"not-started",C=(e,t)=>{d.value=e,c.value=t};return(0,l.bv)((()=>{p()})),(e,t)=>((0,l.wg)(),(0,l.iD)("div",wr,[(0,l._)("div",hr,[(0,l._)("div",xr,[(0,l.Wm)((0,r.SU)(x),{variant:"ghost",size:"sm",onClick:f,class:"mb-4"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(Ka.Z),{class:"h-4 w-4 mr-2"}),t[1]||(t[1]=(0,l.Uk)(" Back to Dashboard ",-1))])),_:1,__:[1]}),(0,l._)("div",br,[(0,l._)("div",null,[(0,l._)("h1",yr,(0,n.zw)(o.value?.name||"Loading..."),1),(0,l._)("p",kr,[o.value?.group?((0,l.wg)(),(0,l.iD)("span",_r,(0,n.zw)(o.value.group)+" • ",1)):(0,l.kq)("",!0),m.value?((0,l.wg)(),(0,l.iD)("span",Sr,(0,n.zw)(u.value&&u.value.timestamp!==g.value[0]?.timestamp?"Ran":"Last run")+" "+(0,n.zw)(w(m.value.timestamp)),1)):(0,l.kq)("",!0)])]),(0,l._)("div",Dr,[m.value?((0,l.wg)(),(0,l.j4)(tt,{key:0,status:m.value.success?"healthy":"unhealthy"},null,8,["status"])):(0,l.kq)("",!0),(0,l.Wm)((0,r.SU)(x),{variant:"ghost",size:"icon",onClick:v,title:"Refresh"},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(Ie.Z),{class:"h-5 w-5"})])),_:1})])])]),a.value?((0,l.wg)(),(0,l.iD)("div",Ur,[(0,l.Wm)(de,{size:"lg"})])):o.value?((0,l.wg)(),(0,l.iD)("div",zr,[m.value?((0,l.wg)(),(0,l.j4)((0,r.SU)(k),{key:0},{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),null,{default:(0,l.w5)((()=>[(0,l.Uk)((0,n.zw)(u.value?.timestamp===g.value[0]?.timestamp?"Latest Execution":`Execution at ${h(u.value.timestamp)}`),1)])),_:1})])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[(0,l._)("div",Wr,[(0,l._)("div",Hr,[(0,l._)("div",null,[t[4]||(t[4]=(0,l._)("p",{class:"text-sm text-muted-foreground"},"Status",-1)),(0,l._)("p",jr,(0,n.zw)(m.value.success?"Success":"Failed"),1)]),(0,l._)("div",null,[t[5]||(t[5]=(0,l._)("p",{class:"text-sm text-muted-foreground"},"Duration",-1)),(0,l._)("p",Rr,(0,n.zw)((0,r.SU)(Xl)(m.value.duration)),1)]),(0,l._)("div",null,[t[6]||(t[6]=(0,l._)("p",{class:"text-sm text-muted-foreground"},"Endpoints",-1)),(0,l._)("p",Fr,(0,n.zw)(m.value.endpointResults?.length||0),1)]),(0,l._)("div",null,[t[7]||(t[7]=(0,l._)("p",{class:"text-sm text-muted-foreground"},"Success Rate",-1)),(0,l._)("p",Tr,(0,n.zw)(b(m.value))+"%",1)])]),(0,l._)("div",Er,[t[8]||(t[8]=(0,l._)("h3",{class:"text-lg font-semibold mb-4"},"Execution Flow",-1)),(0,l.Wm)(Wn,{"flow-steps":y.value,"progress-percentage":S.value,"completed-steps":_.value,"total-steps":y.value.length,onStepSelected:C},null,8,["flow-steps","progress-percentage","completed-steps","total-steps"])]),m.value.errors&&m.value.errors.length>0?((0,l.wg)(),(0,l.iD)("div",qr,[t[9]||(t[9]=(0,l._)("h3",{class:"text-lg font-semibold mb-3 text-red-500"},"Suite Errors",-1)),(0,l._)("div",$r,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(m.value.errors,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:"bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300 p-3 rounded-md text-sm"},(0,n.zw)(e),1)))),128))])])):(0,l.kq)("",!0)])])),_:1})])),_:1})):(0,l.kq)("",!0),(0,l.Wm)((0,r.SU)(k),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(D),null,{default:(0,l.w5)((()=>[(0,l.Wm)((0,r.SU)(z),null,{default:(0,l.w5)((()=>t[10]||(t[10]=[(0,l.Uk)("Execution History",-1)]))),_:1,__:[10]})])),_:1}),(0,l.Wm)((0,r.SU)(j),null,{default:(0,l.w5)((()=>[g.value.length>0?((0,l.wg)(),(0,l.iD)("div",Lr,[((0,l.wg)(!0),(0,l.iD)(l.HY,null,(0,l.Ko)(g.value,((e,t)=>((0,l.wg)(),(0,l.iD)("div",{key:t,class:(0,n.C_)(["flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer",{"bg-accent":u.value&&u.value.timestamp===e.timestamp}]),onClick:t=>u.value=e},[(0,l._)("div",Mr,[(0,l.Wm)(tt,{status:e.success?"healthy":"unhealthy",size:"sm"},null,8,["status"]),(0,l._)("div",null,[(0,l._)("p",Ar,(0,n.zw)(h(e.timestamp)),1),(0,l._)("p",Nr,(0,n.zw)(e.endpointResults?.length||0)+" endpoints • "+(0,n.zw)((0,r.SU)(Xl)(e.duration)),1)])]),(0,l.Wm)((0,r.SU)(Be.Z),{class:"h-4 w-4 text-muted-foreground"})],10,Zr)))),128))])):((0,l.wg)(),(0,l.iD)("div",Ir," No execution history available "))])),_:1})])),_:1})])):((0,l.wg)(),(0,l.iD)("div",Cr,[(0,l.Wm)((0,r.SU)(Ye.Z),{class:"h-12 w-12 text-muted-foreground mx-auto mb-4"}),t[2]||(t[2]=(0,l._)("h3",{class:"text-lg font-semibold mb-2"},"Suite not found",-1)),t[3]||(t[3]=(0,l._)("p",{class:"text-muted-foreground"},"The requested suite could not be found.",-1))]))]),(0,l.Wm)(ys,{onRefreshData:p}),d.value?((0,l.wg)(),(0,l.j4)(fr,{key:0,step:d.value,index:c.value,onClose:t[0]||(t[0]=e=>d.value=null)},null,8,["step","index"])):(0,l.kq)("",!0)]))}};const Or=(0,T.Z)(Yr,[["__scopeId","data-v-e2a91c9e"]]);var Pr=Or;const Kr=[{path:"/",name:"Home",component:Pa},{path:"/endpoints/:key",name:"EndpointDetails",component:Vl},{path:"/suites/:key",name:"SuiteDetails",component:Pr}],Vr=(0,i.p7)({history:(0,i.PO)("/"),routes:Kr});var Br=Vr;(0,a.ri)(Me).use(Br).mount("#app")}},t={};function s(a){var l=t[a];if(void 0!==l)return l.exports;var n=t[a]={exports:{}};return e[a](n,n.exports,s),n.exports}s.m=e,function(){var e=[];s.O=function(t,a,l,n){if(!a){var r=1/0;for(d=0;d=n)&&Object.keys(s.O).every((function(e){return s.O[e](a[i])}))?a.splice(i--,1):(o=!1,n0&&e[d-1][2]>n;d--)e[d]=e[d-1];e[d]=[a,l,n]}}(),function(){s.d=function(e,t){for(var a in t)s.o(t,a)&&!s.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})}}(),function(){s.g=function(){if("object"===typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"===typeof window)return window}}()}(),function(){s.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}}(),function(){s.p="/"}(),function(){var e={143:0};s.O.j=function(t){return 0===e[t]};var t=function(t,a){var l,n,r=a[0],o=a[1],i=a[2],u=0;if(r.some((function(t){return 0!==e[t]}))){for(l in o)s.o(o,l)&&(s.m[l]=o[l]);if(i)var d=i(s)}for(t&&t(a);u0&&0===--this._on&&(r=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){let e,n;for(this._active=!1,e=0,n=this.effects.length;e0)return;if(h){let t=h;h=void 0;while(t){const e=t.next;t.next=void 0,t.flags&=-9,t=e}}let t;while(u){let n=u;u=void 0;while(n){const i=n.next;if(n.next=void 0,n.flags&=-9,1&n.flags)try{n.trigger()}catch(e){t||(t=e)}n=i}}if(t)throw t}function m(t){for(let e=t.deps;e;e=e.nextDep)e.version=-1,e.prevActiveLink=e.dep.activeLink,e.dep.activeLink=e}function b(t){let e,n=t.depsTail,i=n;while(i){const t=i.prevDep;-1===i.version?(i===n&&(n=t),v(i),w(i)):e=i,i.dep.activeLink=i.prevActiveLink,i.prevActiveLink=void 0,i=t}t.deps=e,t.depsTail=n}function x(t){for(let e=t.deps;e;e=e.nextDep)if(e.dep.version!==e.version||e.dep.computed&&(y(e.dep.computed)||e.dep.version!==e.version))return!0;return!!t._dirty}function y(t){if(4&t.flags&&!(16&t.flags))return;if(t.flags&=-17,t.globalVersion===D)return;if(t.globalVersion=D,!t.isSSR&&128&t.flags&&(!t.deps&&!t._dirty||!x(t)))return;t.flags|=2;const e=t.dep,n=o,r=k;o=t,k=!0;try{m(t);const s=t.fn(t._value);(0===e.version||(0,i.aU)(s,t._value))&&(t.flags|=128,t._value=s,e.version++)}catch(s){throw e.version++,s}finally{o=n,k=r,b(t),t.flags&=-3}}function v(t,e=!1){const{dep:n,prevSub:i,nextSub:r}=t;if(i&&(i.nextSub=r,t.prevSub=void 0),r&&(r.prevSub=i,t.nextSub=void 0),n.subs===t&&(n.subs=i,!i&&n.computed)){n.computed.flags&=-5;for(let t=n.computed.deps;t;t=t.nextDep)v(t,!0)}e||--n.sc||!n.map||n.map.delete(n.key)}function w(t){const{prevDep:e,nextDep:n}=t;e&&(e.nextDep=n,t.prevDep=void 0),n&&(n.prevDep=e,t.nextDep=void 0)}let k=!0;const _=[];function M(){_.push(k),k=!1}function S(){const t=_.pop();k=void 0===t||t}function T(t){const{cleanup:e}=t;if(t.cleanup=void 0,e){const t=o;o=void 0;try{e()}finally{o=t}}}let D=0;class C{constructor(t,e){this.sub=t,this.dep=e,this.version=e.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class A{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!o||!k||o===this.computed)return;let e=this.activeLink;if(void 0===e||e.sub!==o)e=this.activeLink=new C(o,this),o.deps?(e.prevDep=o.depsTail,o.depsTail.nextDep=e,o.depsTail=e):o.deps=o.depsTail=e,O(e);else if(-1===e.version&&(e.version=this.version,e.nextDep)){const t=e.nextDep;t.prevDep=e.prevDep,e.prevDep&&(e.prevDep.nextDep=t),e.prevDep=o.depsTail,e.nextDep=void 0,o.depsTail.nextDep=e,o.depsTail=e,o.deps===e&&(o.deps=t)}return e}trigger(t){this.version++,D++,this.notify(t)}notify(t){p();try{0;for(let t=this.subs;t;t=t.prevSub)t.sub.notify()&&t.sub.dep.notify()}finally{g()}}}function O(t){if(t.dep.sc++,4&t.sub.flags){const e=t.dep.computed;if(e&&!t.dep.subs){e.flags|=20;for(let t=e.deps;t;t=t.nextDep)O(t)}const n=t.dep.subs;n!==t&&(t.prevSub=n,n&&(n.nextSub=t)),t.dep.subs=t}}const P=new WeakMap,E=Symbol(""),R=Symbol(""),I=Symbol("");function L(t,e,n){if(k&&o){let e=P.get(t);e||P.set(t,e=new Map);let i=e.get(n);i||(e.set(n,i=new A),i.map=e,i.key=n),i.track()}}function z(t,e,n,r,o,s){const a=P.get(t);if(!a)return void D++;const l=t=>{t&&t.trigger()};if(p(),"clear"===e)a.forEach(l);else{const o=(0,i.kJ)(t),s=o&&(0,i.S0)(n);if(o&&"length"===n){const t=Number(r);a.forEach(((e,n)=>{("length"===n||n===I||!(0,i.yk)(n)&&n>=t)&&l(e)}))}else switch((void 0!==n||a.has(void 0))&&l(a.get(n)),s&&l(a.get(I)),e){case"add":o?s&&l(a.get("length")):(l(a.get(E)),(0,i._N)(t)&&l(a.get(R)));break;case"delete":o||(l(a.get(E)),(0,i._N)(t)&&l(a.get(R)));break;case"set":(0,i._N)(t)&&l(a.get(E));break}}g()}function N(t){const e=Mt(t);return e===t?e:(L(e,"iterate",I),kt(t)?e:e.map(Tt))}function F(t){return L(t=Mt(t),"iterate",I),t}const j={__proto__:null,[Symbol.iterator](){return H(this,Symbol.iterator,Tt)},concat(...t){return N(this).concat(...t.map((t=>(0,i.kJ)(t)?N(t):t)))},entries(){return H(this,"entries",(t=>(t[1]=Tt(t[1]),t)))},every(t,e){return $(this,"every",t,e,void 0,arguments)},filter(t,e){return $(this,"filter",t,e,(t=>t.map(Tt)),arguments)},find(t,e){return $(this,"find",t,e,Tt,arguments)},findIndex(t,e){return $(this,"findIndex",t,e,void 0,arguments)},findLast(t,e){return $(this,"findLast",t,e,Tt,arguments)},findLastIndex(t,e){return $(this,"findLastIndex",t,e,void 0,arguments)},forEach(t,e){return $(this,"forEach",t,e,void 0,arguments)},includes(...t){return Y(this,"includes",t)},indexOf(...t){return Y(this,"indexOf",t)},join(t){return N(this).join(t)},lastIndexOf(...t){return Y(this,"lastIndexOf",t)},map(t,e){return $(this,"map",t,e,void 0,arguments)},pop(){return V(this,"pop")},push(...t){return V(this,"push",t)},reduce(t,...e){return B(this,"reduce",t,e)},reduceRight(t,...e){return B(this,"reduceRight",t,e)},shift(){return V(this,"shift")},some(t,e){return $(this,"some",t,e,void 0,arguments)},splice(...t){return V(this,"splice",t)},toReversed(){return N(this).toReversed()},toSorted(t){return N(this).toSorted(t)},toSpliced(...t){return N(this).toSpliced(...t)},unshift(...t){return V(this,"unshift",t)},values(){return H(this,"values",Tt)}};function H(t,e,n){const i=F(t),r=i[e]();return i===t||kt(t)||(r._next=r.next,r.next=()=>{const t=r._next();return t.value&&(t.value=n(t.value)),t}),r}const W=Array.prototype;function $(t,e,n,i,r,o){const s=F(t),a=s!==t&&!kt(t),l=s[e];if(l!==W[e]){const e=l.apply(t,o);return a?Tt(e):e}let c=n;s!==t&&(a?c=function(e,i){return n.call(this,Tt(e),i,t)}:n.length>2&&(c=function(e,i){return n.call(this,e,i,t)}));const u=l.call(s,c,i);return a&&r?r(u):u}function B(t,e,n,i){const r=F(t);let o=n;return r!==t&&(kt(t)?n.length>3&&(o=function(e,i,r){return n.call(this,e,i,r,t)}):o=function(e,i,r){return n.call(this,e,Tt(i),r,t)}),r[e](o,...i)}function Y(t,e,n){const i=Mt(t);L(i,"iterate",I);const r=i[e](...n);return-1!==r&&!1!==r||!_t(n[0])?r:(n[0]=Mt(n[0]),i[e](...n))}function V(t,e,n=[]){M(),p();const i=Mt(t)[e].apply(t,n);return g(),S(),i}const U=(0,i.fY)("__proto__,__v_isRef,__isVue"),q=new Set(Object.getOwnPropertyNames(Symbol).filter((t=>"arguments"!==t&&"caller"!==t)).map((t=>Symbol[t])).filter(i.yk));function X(t){(0,i.yk)(t)||(t=String(t));const e=Mt(this);return L(e,"has",t),e.hasOwnProperty(t)}class G{constructor(t=!1,e=!1){this._isReadonly=t,this._isShallow=e}get(t,e,n){if("__v_skip"===e)return t["__v_skip"];const r=this._isReadonly,o=this._isShallow;if("__v_isReactive"===e)return!r;if("__v_isReadonly"===e)return r;if("__v_isShallow"===e)return o;if("__v_raw"===e)return n===(r?o?ft:dt:o?ht:ut).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(n)?t:void 0;const s=(0,i.kJ)(t);if(!r){let t;if(s&&(t=j[e]))return t;if("hasOwnProperty"===e)return X}const a=Reflect.get(t,e,Ct(t)?t:n);return((0,i.yk)(e)?q.has(e):U(e))?a:(r||L(t,"get",e),o?a:Ct(a)?s&&(0,i.S0)(e)?a:a.value:(0,i.Kn)(a)?r?xt(a):mt(a):a)}}class Z extends G{constructor(t=!1){super(!1,t)}set(t,e,n,r){let o=t[e];if(!this._isShallow){const e=wt(o);if(kt(n)||wt(n)||(o=Mt(o),n=Mt(n)),!(0,i.kJ)(t)&&Ct(o)&&!Ct(n))return!e&&(o.value=n,!0)}const s=(0,i.kJ)(t)&&(0,i.S0)(e)?Number(e)t,nt=t=>Reflect.getPrototypeOf(t);function it(t,e,n){return function(...r){const o=this["__v_raw"],s=Mt(o),a=(0,i._N)(s),l="entries"===t||t===Symbol.iterator&&a,c="keys"===t&&a,u=o[t](...r),h=n?et:e?Dt:Tt;return!e&&L(s,"iterate",c?R:E),{next(){const{value:t,done:e}=u.next();return e?{value:t,done:e}:{value:l?[h(t[0]),h(t[1])]:h(t),done:e}},[Symbol.iterator](){return this}}}}function rt(t){return function(...e){return"delete"!==t&&("clear"===t?void 0:this)}}function ot(t,e){const n={get(n){const r=this["__v_raw"],o=Mt(r),s=Mt(n);t||((0,i.aU)(n,s)&&L(o,"get",n),L(o,"get",s));const{has:a}=nt(o),l=e?et:t?Dt:Tt;return a.call(o,n)?l(r.get(n)):a.call(o,s)?l(r.get(s)):void(r!==o&&r.get(n))},get size(){const e=this["__v_raw"];return!t&&L(Mt(e),"iterate",E),Reflect.get(e,"size",e)},has(e){const n=this["__v_raw"],r=Mt(n),o=Mt(e);return t||((0,i.aU)(e,o)&&L(r,"has",e),L(r,"has",o)),e===o?n.has(e):n.has(e)||n.has(o)},forEach(n,i){const r=this,o=r["__v_raw"],s=Mt(o),a=e?et:t?Dt:Tt;return!t&&L(s,"iterate",E),o.forEach(((t,e)=>n.call(i,a(t),a(e),r)))}};(0,i.l7)(n,t?{add:rt("add"),set:rt("set"),delete:rt("delete"),clear:rt("clear")}:{add(t){e||kt(t)||wt(t)||(t=Mt(t));const n=Mt(this),i=nt(n),r=i.has.call(n,t);return r||(n.add(t),z(n,"add",t,t)),this},set(t,n){e||kt(n)||wt(n)||(n=Mt(n));const r=Mt(this),{has:o,get:s}=nt(r);let a=o.call(r,t);a||(t=Mt(t),a=o.call(r,t));const l=s.call(r,t);return r.set(t,n),a?(0,i.aU)(n,l)&&z(r,"set",t,n,l):z(r,"add",t,n),this},delete(t){const e=Mt(this),{has:n,get:i}=nt(e);let r=n.call(e,t);r||(t=Mt(t),r=n.call(e,t));const o=i?i.call(e,t):void 0,s=e.delete(t);return r&&z(e,"delete",t,void 0,o),s},clear(){const t=Mt(this),e=0!==t.size,n=void 0,i=t.clear();return e&&z(t,"clear",void 0,void 0,n),i}});const r=["keys","values","entries",Symbol.iterator];return r.forEach((i=>{n[i]=it(i,t,e)})),n}function st(t,e){const n=ot(t,e);return(e,r,o)=>"__v_isReactive"===r?!t:"__v_isReadonly"===r?t:"__v_raw"===r?e:Reflect.get((0,i.RI)(n,r)&&r in e?n:e,r,o)}const at={get:st(!1,!1)},lt={get:st(!1,!0)},ct={get:st(!0,!1)};const ut=new WeakMap,ht=new WeakMap,dt=new WeakMap,ft=new WeakMap;function pt(t){switch(t){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function gt(t){return t["__v_skip"]||!Object.isExtensible(t)?0:pt((0,i.W7)(t))}function mt(t){return wt(t)?t:yt(t,!1,J,at,ut)}function bt(t){return yt(t,!1,tt,lt,ht)}function xt(t){return yt(t,!0,K,ct,dt)}function yt(t,e,n,r,o){if(!(0,i.Kn)(t))return t;if(t["__v_raw"]&&(!e||!t["__v_isReactive"]))return t;const s=gt(t);if(0===s)return t;const a=o.get(t);if(a)return a;const l=new Proxy(t,2===s?r:n);return o.set(t,l),l}function vt(t){return wt(t)?vt(t["__v_raw"]):!(!t||!t["__v_isReactive"])}function wt(t){return!(!t||!t["__v_isReadonly"])}function kt(t){return!(!t||!t["__v_isShallow"])}function _t(t){return!!t&&!!t["__v_raw"]}function Mt(t){const e=t&&t["__v_raw"];return e?Mt(e):t}function St(t){return!(0,i.RI)(t,"__v_skip")&&Object.isExtensible(t)&&(0,i.Nj)(t,"__v_skip",!0),t}const Tt=t=>(0,i.Kn)(t)?mt(t):t,Dt=t=>(0,i.Kn)(t)?xt(t):t;function Ct(t){return!!t&&!0===t["__v_isRef"]}function At(t){return Pt(t,!1)}function Ot(t){return Pt(t,!0)}function Pt(t,e){return Ct(t)?t:new Et(t,e)}class Et{constructor(t,e){this.dep=new A,this["__v_isRef"]=!0,this["__v_isShallow"]=!1,this._rawValue=e?t:Mt(t),this._value=e?t:Tt(t),this["__v_isShallow"]=e}get value(){return this.dep.track(),this._value}set value(t){const e=this._rawValue,n=this["__v_isShallow"]||kt(t)||wt(t);t=n?t:Mt(t),(0,i.aU)(t,e)&&(this._rawValue=t,this._value=n?t:Tt(t),this.dep.trigger())}}function Rt(t){return Ct(t)?t.value:t}const It={get:(t,e,n)=>"__v_raw"===e?t:Rt(Reflect.get(t,e,n)),set:(t,e,n,i)=>{const r=t[e];return Ct(r)&&!Ct(n)?(r.value=n,!0):Reflect.set(t,e,n,i)}};function Lt(t){return vt(t)?t:new Proxy(t,It)}class zt{constructor(t,e,n){this.fn=t,this.setter=e,this._value=void 0,this.dep=new A(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=D-1,this.next=void 0,this.effect=this,this["__v_isReadonly"]=!e,this.isSSR=n}notify(){if(this.flags|=16,!(8&this.flags||o===this))return f(this,!0),!0}get value(){const t=this.dep.track();return y(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Nt(t,e,n=!1){let r,o;(0,i.mf)(t)?r=t:(r=t.get,o=t.set);const s=new zt(r,o,n);return s}const Ft={},jt=new WeakMap;let Ht;function Wt(t,e=!1,n=Ht){if(n){let e=jt.get(n);e||jt.set(n,e=[]),e.push(t)}else 0}function $t(t,e,n=i.kT){const{immediate:r,deep:o,once:s,scheduler:l,augmentJob:u,call:h}=n,d=t=>o?t:kt(t)||!1===o||0===o?Bt(t,1):Bt(t);let f,p,g,m,b=!1,x=!1;if(Ct(t)?(p=()=>t.value,b=kt(t)):vt(t)?(p=()=>d(t),b=!0):(0,i.kJ)(t)?(x=!0,b=t.some((t=>vt(t)||kt(t))),p=()=>t.map((t=>Ct(t)?t.value:vt(t)?d(t):(0,i.mf)(t)?h?h(t,2):t():void 0))):p=(0,i.mf)(t)?e?h?()=>h(t,2):t:()=>{if(g){M();try{g()}finally{S()}}const e=Ht;Ht=f;try{return h?h(t,3,[m]):t(m)}finally{Ht=e}}:i.dG,e&&o){const t=p,e=!0===o?1/0:o;p=()=>Bt(t(),e)}const y=a(),v=()=>{f.stop(),y&&y.active&&(0,i.Od)(y.effects,f)};if(s&&e){const t=e;e=(...e)=>{t(...e),v()}}let w=x?new Array(t.length).fill(Ft):Ft;const k=t=>{if(1&f.flags&&(f.dirty||t))if(e){const t=f.run();if(o||b||(x?t.some(((t,e)=>(0,i.aU)(t,w[e]))):(0,i.aU)(t,w))){g&&g();const n=Ht;Ht=f;try{const i=[t,w===Ft?void 0:x&&w[0]===Ft?[]:w,m];w=t,h?h(e,3,i):e(...i)}finally{Ht=n}}}else f.run()};return u&&u(k),f=new c(p),f.scheduler=l?()=>l(k,!1):k,m=t=>Wt(t,!1,f),g=f.onStop=()=>{const t=jt.get(f);if(t){if(h)h(t,4);else for(const e of t)e();jt.delete(f)}},e?r?k(!0):w=f.run():l?l(k.bind(null,!0),!0):f.run(),v.pause=f.pause.bind(f),v.resume=f.resume.bind(f),v.stop=v,v}function Bt(t,e=1/0,n){if(e<=0||!(0,i.Kn)(t)||t["__v_skip"])return t;if(n=n||new Set,n.has(t))return t;if(n.add(t),e--,Ct(t))Bt(t.value,e,n);else if((0,i.kJ)(t))for(let i=0;i{Bt(t,e,n)}));else if((0,i.PO)(t)){for(const i in t)Bt(t[i],e,n);for(const i of Object.getOwnPropertySymbols(t))Object.prototype.propertyIsEnumerable.call(t,i)&&Bt(t[i],e,n)}return t}},252:function(t,e,n){n.d(e,{$d:function(){return s},Ah:function(){return at},FN:function(){return vn},Fl:function(){return Fn},HY:function(){return He},JJ:function(){return Vt},Ko:function(){return xt},LL:function(){return gt},Q6:function(){return W},U2:function(){return j},Uk:function(){return cn},Us:function(){return fe},WI:function(){return yt},Wm:function(){return on},Y3:function(){return m},Y8:function(){return L},YP:function(){return Me},_:function(){return rn},aZ:function(){return $},bv:function(){return it},f3:function(){return Ut},h:function(){return jn},i8:function(){return Hn},iD:function(){return Qe},ic:function(){return ot},j4:function(){return Je},kq:function(){return un},nJ:function(){return N},nK:function(){return H},up:function(){return ft},w5:function(){return C},wg:function(){return Ue},wy:function(){return A}});var i=n(262),r=n(577);function o(t,e,n,i){try{return i?t(...i):t()}catch(r){a(r,e,n)}}function s(t,e,n,i){if((0,r.mf)(t)){const s=o(t,e,n,i);return s&&(0,r.tI)(s)&&s.catch((t=>{a(t,e,n)})),s}if((0,r.kJ)(t)){const r=[];for(let o=0;o>>1,r=c[i],o=_(r);o=_(n)?c.push(t):c.splice(b(e),0,t),t.flags|=1,y()}}function y(){g||(g=p.then(M))}function v(t){(0,r.kJ)(t)?h.push(...t):d&&-1===t.id?d.splice(f+1,0,t):1&t.flags||(h.push(t),t.flags|=1),y()}function w(t,e,n=u+1){for(0;n_(t)-_(e)));if(h.length=0,d)return void d.push(...t);for(d=t,f=0;fnull==t.id?2&t.flags?-1:1/0:t.id;function M(t){r.dG;try{for(u=0;u{i._d&&Ge(-1);const r=D(e);let o;try{o=t(...n)}finally{D(r),i._d&&Ge(1)}return o};return i._n=!0,i._c=!0,i._d=!0,i}function A(t,e){if(null===S)return t;const n=Ln(S),o=t.dirs||(t.dirs=[]);for(let s=0;st.__isTeleport;const R=Symbol("_leaveCb"),I=Symbol("_enterCb");function L(){const t={isMounted:!1,isLeaving:!1,isUnmounting:!1,leavingVNodes:new Map};return it((()=>{t.isMounted=!0})),st((()=>{t.isUnmounting=!0})),t}const z=[Function,Array],N={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:z,onEnter:z,onAfterEnter:z,onEnterCancelled:z,onBeforeLeave:z,onLeave:z,onAfterLeave:z,onLeaveCancelled:z,onBeforeAppear:z,onAppear:z,onAfterAppear:z,onAppearCancelled:z};function F(t,e){const{leavingVNodes:n}=t;let i=n.get(e.type);return i||(i=Object.create(null),n.set(e.type,i)),i}function j(t,e,n,i,o){const{appear:a,mode:l,persisted:c=!1,onBeforeEnter:u,onEnter:h,onAfterEnter:d,onEnterCancelled:f,onBeforeLeave:p,onLeave:g,onAfterLeave:m,onLeaveCancelled:b,onBeforeAppear:x,onAppear:y,onAfterAppear:v,onAppearCancelled:w}=e,k=String(t.key),_=F(n,t),M=(t,e)=>{t&&s(t,i,9,e)},S=(t,e)=>{const n=e[1];M(t,e),(0,r.kJ)(t)?t.every((t=>t.length<=1))&&n():t.length<=1&&n()},T={mode:l,persisted:c,beforeEnter(e){let i=u;if(!n.isMounted){if(!a)return;i=x||u}e[R]&&e[R](!0);const r=_[k];r&&tn(t,r)&&r.el[R]&&r.el[R](),M(i,[e])},enter(t){let e=h,i=d,r=f;if(!n.isMounted){if(!a)return;e=y||h,i=v||d,r=w||f}let o=!1;const s=t[I]=e=>{o||(o=!0,M(e?r:i,[t]),T.delayedLeave&&T.delayedLeave(),t[I]=void 0)};e?S(e,[t,s]):s()},leave(e,i){const r=String(t.key);if(e[I]&&e[I](!0),n.isUnmounting)return i();M(p,[e]);let o=!1;const s=e[R]=n=>{o||(o=!0,i(),M(n?b:m,[e]),e[R]=void 0,_[r]===t&&delete _[r])};_[r]=t,g?S(g,[e,s]):s()},clone(t){const r=j(t,e,n,i,o);return o&&o(r),r}};return T}function H(t,e){6&t.shapeFlag&&t.component?(t.transition=e,H(t.component.subTree,e)):128&t.shapeFlag?(t.ssContent.transition=e.clone(t.ssContent),t.ssFallback.transition=e.clone(t.ssFallback)):t.transition=e}function W(t,e=!1,n){let i=[],r=0;for(let o=0;o1)for(let o=0;o(0,r.l7)({name:t.name},e,{setup:t}))():t}function B(t){t.ids=[t.ids[0]+t.ids[2]+++"-",0,0]}function Y(t,e,n,s,a=!1){if((0,r.kJ)(t))return void t.forEach(((t,i)=>Y(t,e&&((0,r.kJ)(e)?e[i]:e),n,s,a)));if(V(s)&&!a)return void(512&s.shapeFlag&&s.type.__asyncResolved&&s.component.subTree.component&&Y(t,e,n,s.component.subTree));const l=4&s.shapeFlag?Ln(s.component):s.el,c=a?null:l,{i:u,r:h}=t;const d=e&&e.r,f=u.refs===r.kT?u.refs={}:u.refs,p=u.setupState,g=(0,i.IU)(p),m=p===r.kT?()=>!1:t=>(0,r.RI)(g,t);if(null!=d&&d!==h&&((0,r.HD)(d)?(f[d]=null,m(d)&&(p[d]=null)):(0,i.dq)(d)&&(d.value=null)),(0,r.mf)(h))o(h,u,12,[c,f]);else{const e=(0,r.HD)(h),o=(0,i.dq)(h);if(e||o){const i=()=>{if(t.f){const n=e?m(h)?p[h]:f[h]:h.value;a?(0,r.kJ)(n)&&(0,r.Od)(n,l):(0,r.kJ)(n)?n.includes(l)||n.push(l):e?(f[h]=[l],m(h)&&(p[h]=f[h])):(h.value=[l],t.k&&(f[t.k]=h.value))}else e?(f[h]=c,m(h)&&(p[h]=c)):o&&(h.value=c,t.k&&(f[t.k]=c))};c?(i.id=-1,de(i,n)):i()}else 0}}(0,r.E9)().requestIdleCallback,(0,r.E9)().cancelIdleCallback;const V=t=>!!t.type.__asyncLoader
/*! #__NO_SIDE_EFFECTS__ */;const U=t=>t.type.__isKeepAlive;RegExp,RegExp;function q(t,e){return(0,r.kJ)(t)?t.some((t=>q(t,e))):(0,r.HD)(t)?t.split(",").includes(e):!!(0,r.Kj)(t)&&(t.lastIndex=0,t.test(e))}function X(t,e){Z(t,"a",e)}function G(t,e){Z(t,"da",e)}function Z(t,e,n=yn){const i=t.__wdc||(t.__wdc=()=>{let e=n;while(e){if(e.isDeactivated)return;e=e.parent}return t()});if(tt(e,i,n),n){let t=n.parent;while(t&&t.parent)U(t.parent.vnode)&&Q(i,e,n,t),t=t.parent}}function Q(t,e,n,i){const o=tt(e,t,i,!0);at((()=>{(0,r.Od)(i[e],o)}),n)}function J(t){t.shapeFlag&=-257,t.shapeFlag&=-513}function K(t){return 128&t.shapeFlag?t.ssContent:t}function tt(t,e,n=yn,r=!1){if(n){const o=n[t]||(n[t]=[]),a=e.__weh||(e.__weh=(...r)=>{(0,i.Jd)();const o=_n(n),a=s(e,n,t,r);return o(),(0,i.lk)(),a});return r?o.unshift(a):o.push(a),a}}const et=t=>(e,n=yn)=>{Cn&&"sp"!==t||tt(t,((...t)=>e(...t)),n)},nt=et("bm"),it=et("m"),rt=et("bu"),ot=et("u"),st=et("bum"),at=et("um"),lt=et("sp"),ct=et("rtg"),ut=et("rtc");function ht(t,e=yn){tt("ec",t,e)}const dt="components";function ft(t,e){return mt(dt,t,!0,e)||t}const pt=Symbol.for("v-ndc");function gt(t){return(0,r.HD)(t)?mt(dt,t,!1)||t:t||pt}function mt(t,e,n=!0,i=!1){const o=S||yn;if(o){const n=o.type;if(t===dt){const t=zn(n,!1);if(t&&(t===e||t===(0,r._A)(e)||t===(0,r.kC)((0,r._A)(e))))return n}const s=bt(o[t]||n[t],e)||bt(o.appContext[t],e);return!s&&i?n:s}}function bt(t,e){return t&&(t[e]||t[(0,r._A)(e)]||t[(0,r.kC)((0,r._A)(e))])}function xt(t,e,n,o){let s;const a=n&&n[o],l=(0,r.kJ)(t);if(l||(0,r.HD)(t)){const n=l&&(0,i.PG)(t);let r=!1,o=!1;n&&(r=!(0,i.yT)(t),o=(0,i.$y)(t),t=(0,i.XB)(t)),s=new Array(t.length);for(let l=0,c=t.length;le(t,n,void 0,a&&a[n])));else{const n=Object.keys(t);s=new Array(n.length);for(let i=0,r=n.length;i!Ke(t)||t.type!==$e&&!(t.type===He&&!vt(t.children))))?t:null}const wt=t=>t?Sn(t)?Ln(t):wt(t.parent):null,kt=(0,r.l7)(Object.create(null),{$:t=>t,$el:t=>t.vnode.el,$data:t=>t.data,$props:t=>t.props,$attrs:t=>t.attrs,$slots:t=>t.slots,$refs:t=>t.refs,$parent:t=>wt(t.parent),$root:t=>wt(t.root),$host:t=>t.ce,$emit:t=>t.emit,$options:t=>Pt(t),$forceUpdate:t=>t.f||(t.f=()=>{x(t.update)}),$nextTick:t=>t.n||(t.n=m.bind(t.proxy)),$watch:t=>Te.bind(t)}),_t=(t,e)=>t!==r.kT&&!t.__isScriptSetup&&(0,r.RI)(t,e),Mt={get({_:t},e){if("__v_skip"===e)return!0;const{ctx:n,setupState:o,data:s,props:a,accessCache:l,type:c,appContext:u}=t;let h;if("$"!==e[0]){const i=l[e];if(void 0!==i)switch(i){case 1:return o[e];case 2:return s[e];case 4:return n[e];case 3:return a[e]}else{if(_t(o,e))return l[e]=1,o[e];if(s!==r.kT&&(0,r.RI)(s,e))return l[e]=2,s[e];if((h=t.propsOptions[0])&&(0,r.RI)(h,e))return l[e]=3,a[e];if(n!==r.kT&&(0,r.RI)(n,e))return l[e]=4,n[e];Tt&&(l[e]=0)}}const d=kt[e];let f,p;return d?("$attrs"===e&&(0,i.j)(t.attrs,"get",""),d(t)):(f=c.__cssModules)&&(f=f[e])?f:n!==r.kT&&(0,r.RI)(n,e)?(l[e]=4,n[e]):(p=u.config.globalProperties,(0,r.RI)(p,e)?p[e]:void 0)},set({_:t},e,n){const{data:i,setupState:o,ctx:s}=t;return _t(o,e)?(o[e]=n,!0):i!==r.kT&&(0,r.RI)(i,e)?(i[e]=n,!0):!(0,r.RI)(t.props,e)&&(("$"!==e[0]||!(e.slice(1)in t))&&(s[e]=n,!0))},has({_:{data:t,setupState:e,accessCache:n,ctx:i,appContext:o,propsOptions:s}},a){let l;return!!n[a]||t!==r.kT&&(0,r.RI)(t,a)||_t(e,a)||(l=s[0])&&(0,r.RI)(l,a)||(0,r.RI)(i,a)||(0,r.RI)(kt,a)||(0,r.RI)(o.config.globalProperties,a)},defineProperty(t,e,n){return null!=n.get?t._.accessCache[e]=0:(0,r.RI)(n,"value")&&this.set(t,e,n.value,null),Reflect.defineProperty(t,e,n)}};function St(t){return(0,r.kJ)(t)?t.reduce(((t,e)=>(t[e]=null,t)),{}):t}let Tt=!0;function Dt(t){const e=Pt(t),n=t.proxy,o=t.ctx;Tt=!1,e.beforeCreate&&At(e.beforeCreate,t,"bc");const{data:s,computed:a,methods:l,watch:c,provide:u,inject:h,created:d,beforeMount:f,mounted:p,beforeUpdate:g,updated:m,activated:b,deactivated:x,beforeDestroy:y,beforeUnmount:v,destroyed:w,unmounted:k,render:_,renderTracked:M,renderTriggered:S,errorCaptured:T,serverPrefetch:D,expose:C,inheritAttrs:A,components:O,directives:P,filters:E}=e,R=null;if(h&&Ct(h,o,R),l)for(const i in l){const t=l[i];(0,r.mf)(t)&&(o[i]=t.bind(n))}if(s){0;const e=s.call(n,n);0,(0,r.Kn)(e)&&(t.data=(0,i.qj)(e))}if(Tt=!0,a)for(const i in a){const t=a[i],e=(0,r.mf)(t)?t.bind(n,n):(0,r.mf)(t.get)?t.get.bind(n,n):r.dG;0;const s=!(0,r.mf)(t)&&(0,r.mf)(t.set)?t.set.bind(n):r.dG,l=Fn({get:e,set:s});Object.defineProperty(o,i,{enumerable:!0,configurable:!0,get:()=>l.value,set:t=>l.value=t})}if(c)for(const i in c)Ot(c[i],o,n,i);if(u){const t=(0,r.mf)(u)?u.call(n):u;Reflect.ownKeys(t).forEach((e=>{Vt(e,t[e])}))}function I(t,e){(0,r.kJ)(e)?e.forEach((e=>t(e.bind(n)))):e&&t(e.bind(n))}if(d&&At(d,t,"c"),I(nt,f),I(it,p),I(rt,g),I(ot,m),I(X,b),I(G,x),I(ht,T),I(ut,M),I(ct,S),I(st,v),I(at,k),I(lt,D),(0,r.kJ)(C))if(C.length){const e=t.exposed||(t.exposed={});C.forEach((t=>{Object.defineProperty(e,t,{get:()=>n[t],set:e=>n[t]=e,enumerable:!0})}))}else t.exposed||(t.exposed={});_&&t.render===r.dG&&(t.render=_),null!=A&&(t.inheritAttrs=A),O&&(t.components=O),P&&(t.directives=P),D&&B(t)}function Ct(t,e,n=r.dG){(0,r.kJ)(t)&&(t=zt(t));for(const o in t){const n=t[o];let s;s=(0,r.Kn)(n)?"default"in n?Ut(n.from||o,n.default,!0):Ut(n.from||o):Ut(n),(0,i.dq)(s)?Object.defineProperty(e,o,{enumerable:!0,configurable:!0,get:()=>s.value,set:t=>s.value=t}):e[o]=s}}function At(t,e,n){s((0,r.kJ)(t)?t.map((t=>t.bind(e.proxy))):t.bind(e.proxy),e,n)}function Ot(t,e,n,i){let o=i.includes(".")?De(n,i):()=>n[i];if((0,r.HD)(t)){const n=e[t];(0,r.mf)(n)&&Me(o,n)}else if((0,r.mf)(t))Me(o,t.bind(n));else if((0,r.Kn)(t))if((0,r.kJ)(t))t.forEach((t=>Ot(t,e,n,i)));else{const i=(0,r.mf)(t.handler)?t.handler.bind(n):e[t.handler];(0,r.mf)(i)&&Me(o,i,t)}else 0}function Pt(t){const e=t.type,{mixins:n,extends:i}=e,{mixins:o,optionsCache:s,config:{optionMergeStrategies:a}}=t.appContext,l=s.get(e);let c;return l?c=l:o.length||n||i?(c={},o.length&&o.forEach((t=>Et(c,t,a,!0))),Et(c,e,a)):c=e,(0,r.Kn)(e)&&s.set(e,c),c}function Et(t,e,n,i=!1){const{mixins:r,extends:o}=e;o&&Et(t,o,n,!0),r&&r.forEach((e=>Et(t,e,n,!0)));for(const s in e)if(i&&"expose"===s);else{const i=Rt[s]||n&&n[s];t[s]=i?i(t[s],e[s]):e[s]}return t}const Rt={data:It,props:jt,emits:jt,methods:Ft,computed:Ft,beforeCreate:Nt,created:Nt,beforeMount:Nt,mounted:Nt,beforeUpdate:Nt,updated:Nt,beforeDestroy:Nt,beforeUnmount:Nt,destroyed:Nt,unmounted:Nt,activated:Nt,deactivated:Nt,errorCaptured:Nt,serverPrefetch:Nt,components:Ft,directives:Ft,watch:Ht,provide:It,inject:Lt};function It(t,e){return e?t?function(){return(0,r.l7)((0,r.mf)(t)?t.call(this,this):t,(0,r.mf)(e)?e.call(this,this):e)}:e:t}function Lt(t,e){return Ft(zt(t),zt(e))}function zt(t){if((0,r.kJ)(t)){const e={};for(let n=0;n1)return n&&(0,r.mf)(e)?e.call(i&&i.proxy):e}else 0}const qt={},Xt=()=>Object.create(qt),Gt=t=>Object.getPrototypeOf(t)===qt;function Zt(t,e,n,r=!1){const o={},s=Xt();t.propsDefaults=Object.create(null),Jt(t,e,o,s);for(const i in t.propsOptions[0])i in o||(o[i]=void 0);n?t.props=r?o:(0,i.Um)(o):t.type.props?t.props=o:t.props=s,t.attrs=s}function Qt(t,e,n,o){const{props:s,attrs:a,vnode:{patchFlag:l}}=t,c=(0,i.IU)(s),[u]=t.propsOptions;let h=!1;if(!(o||l>0)||16&l){let i;Jt(t,e,s,a)&&(h=!0);for(const o in c)e&&((0,r.RI)(e,o)||(i=(0,r.rs)(o))!==o&&(0,r.RI)(e,i))||(u?!n||void 0===n[o]&&void 0===n[i]||(s[o]=Kt(u,c,o,void 0,t,!0)):delete s[o]);if(a!==c)for(const t in a)e&&(0,r.RI)(e,t)||(delete a[t],h=!0)}else if(8&l){const n=t.vnode.dynamicProps;for(let i=0;i{c=!0;const[n,i]=ee(t,e,!0);(0,r.l7)(a,n),i&&l.push(...i)};!n&&e.mixins.length&&e.mixins.forEach(i),t.extends&&i(t.extends),t.mixins&&t.mixins.forEach(i)}if(!s&&!c)return(0,r.Kn)(t)&&i.set(t,r.Z6),r.Z6;if((0,r.kJ)(s))for(let h=0;h"_"===t||"__"===t||"_ctx"===t||"$stable"===t,re=t=>(0,r.kJ)(t)?t.map(hn):[hn(t)],oe=(t,e,n)=>{if(e._n)return e;const i=C(((...t)=>re(e(...t))),n);return i._c=!1,i},se=(t,e,n)=>{const i=t._ctx;for(const o in t){if(ie(o))continue;const n=t[o];if((0,r.mf)(n))e[o]=oe(o,n,i);else if(null!=n){0;const t=re(n);e[o]=()=>t}}},ae=(t,e)=>{const n=re(e);t.slots.default=()=>n},le=(t,e,n)=>{for(const i in e)!n&&ie(i)||(t[i]=e[i])},ce=(t,e,n)=>{const i=t.slots=Xt();if(32&t.vnode.shapeFlag){const t=e.__;t&&(0,r.Nj)(i,"__",t,!0);const o=e._;o?(le(i,e,n),n&&(0,r.Nj)(i,"_",o,!0)):se(e,i)}else e&&ae(t,e)},ue=(t,e,n)=>{const{vnode:i,slots:o}=t;let s=!0,a=r.kT;if(32&i.shapeFlag){const t=e._;t?n&&1===t?s=!1:le(o,e,n):(s=!e.$stable,se(e,o)),a=e}else e&&(ae(t,e),a={default:1});if(s)for(const r in o)ie(r)||null!=a[r]||delete o[r]};function he(){"boolean"!==typeof __VUE_PROD_HYDRATION_MISMATCH_DETAILS__&&((0,r.E9)().__VUE_PROD_HYDRATION_MISMATCH_DETAILS__=!1)}const de=je;function fe(t){return pe(t)}function pe(t,e){he();const n=(0,r.E9)();n.__VUE__=!0;const{insert:o,remove:s,patchProp:a,createElement:l,createText:c,createComment:u,setText:h,setElementText:d,parentNode:f,nextSibling:p,setScopeId:g=r.dG,insertStaticContent:m}=t,b=(t,e,n,i=null,r=null,o=null,s,a=null,l=!!e.dynamicChildren)=>{if(t===e)return;t&&!tn(t,e)&&(i=K(t),X(t,r,o,!0),t=null),-2===e.patchFlag&&(l=!1,e.dynamicChildren=null);const{type:c,ref:u,shapeFlag:h}=e;switch(c){case We:y(t,e,n,i);break;case $e:v(t,e,n,i);break;case Be:null==t&&_(e,n,i,s);break;case He:L(t,e,n,i,r,o,s,a,l);break;default:1&h?T(t,e,n,i,r,o,s,a,l):6&h?z(t,e,n,i,r,o,s,a,l):(64&h||128&h)&&c.process(t,e,n,i,r,o,s,a,l,nt)}null!=u&&r?Y(u,t&&t.ref,o,e||t,!e):null==u&&t&&null!=t.ref&&Y(t.ref,null,o,t,!0)},y=(t,e,n,i)=>{if(null==t)o(e.el=c(e.children),n,i);else{const n=e.el=t.el;e.children!==t.children&&h(n,e.children)}},v=(t,e,n,i)=>{null==t?o(e.el=u(e.children||""),n,i):e.el=t.el},_=(t,e,n,i)=>{[t.el,t.anchor]=m(t.children,e,n,i,t.el,t.anchor)},M=({el:t,anchor:e},n,i)=>{let r;while(t&&t!==e)r=p(t),o(t,n,i),t=r;o(e,n,i)},S=({el:t,anchor:e})=>{let n;while(t&&t!==e)n=p(t),s(t),t=n;s(e)},T=(t,e,n,i,r,o,s,a,l)=>{"svg"===e.type?s="svg":"math"===e.type&&(s="mathml"),null==t?D(e,n,i,r,o,s,a,l):E(t,e,r,o,s,a,l)},D=(t,e,n,i,s,c,u,h)=>{let f,p;const{props:g,shapeFlag:m,transition:b,dirs:x}=t;if(f=t.el=l(t.type,c,g&&g.is,g),8&m?d(f,t.children):16&m&&A(t.children,f,null,i,s,ge(t,c),u,h),x&&O(t,null,i,"created"),C(f,t,t.scopeId,u,i),g){for(const t in g)"value"===t||(0,r.Gg)(t)||a(f,t,null,g[t],c,i);"value"in g&&a(f,"value",null,g.value,c),(p=g.onVnodeBeforeMount)&&gn(p,i,t)}x&&O(t,null,i,"beforeMount");const y=be(s,b);y&&b.beforeEnter(f),o(f,e,n),((p=g&&g.onVnodeMounted)||y||x)&&de((()=>{p&&gn(p,i,t),y&&b.enter(f),x&&O(t,null,i,"mounted")}),s)},C=(t,e,n,i,r)=>{if(n&&g(t,n),i)for(let o=0;o{for(let c=l;c{const c=e.el=t.el;let{patchFlag:u,dynamicChildren:h,dirs:f}=e;u|=16&t.patchFlag;const p=t.props||r.kT,g=e.props||r.kT;let m;if(n&&me(n,!1),(m=g.onVnodeBeforeUpdate)&&gn(m,n,e,t),f&&O(e,t,n,"beforeUpdate"),n&&me(n,!0),(p.innerHTML&&null==g.innerHTML||p.textContent&&null==g.textContent)&&d(c,""),h?R(t.dynamicChildren,h,c,n,i,ge(e,o),s):l||W(t,e,c,null,n,i,ge(e,o),s,!1),u>0){if(16&u)I(c,p,g,n,o);else if(2&u&&p.class!==g.class&&a(c,"class",null,g.class,o),4&u&&a(c,"style",p.style,g.style,o),8&u){const t=e.dynamicProps;for(let e=0;e{m&&gn(m,n,e,t),f&&O(e,t,n,"updated")}),i)},R=(t,e,n,i,r,o,s)=>{for(let a=0;a{if(e!==n){if(e!==r.kT)for(const s in e)(0,r.Gg)(s)||s in n||a(t,s,e[s],null,o,i);for(const s in n){if((0,r.Gg)(s))continue;const l=n[s],c=e[s];l!==c&&"value"!==s&&a(t,s,c,l,o,i)}"value"in n&&a(t,"value",e.value,n.value,o)}},L=(t,e,n,i,r,s,a,l,u)=>{const h=e.el=t?t.el:c(""),d=e.anchor=t?t.anchor:c("");let{patchFlag:f,dynamicChildren:p,slotScopeIds:g}=e;g&&(l=l?l.concat(g):g),null==t?(o(h,n,i),o(d,n,i),A(e.children||[],n,d,r,s,a,l,u)):f>0&&64&f&&p&&t.dynamicChildren?(R(t.dynamicChildren,p,n,r,s,a,l),(null!=e.key||r&&e===r.subTree)&&xe(t,e,!0)):W(t,e,n,d,r,s,a,l,u)},z=(t,e,n,i,r,o,s,a,l)=>{e.slotScopeIds=a,null==t?512&e.shapeFlag?r.ctx.activate(e,n,i,s,l):N(e,n,i,r,o,s,l):F(t,e,l)},N=(t,e,n,i,r,o,s)=>{const a=t.component=xn(t,i,r);if(U(t)&&(a.ctx.renderer=nt),An(a,!1,s),a.asyncDep){if(r&&r.registerDep(a,j,s),!t.el){const i=a.subTree=on($e);v(null,i,e,n),t.placeholder=i.el}}else j(a,t,e,n,r,o,s)},F=(t,e,n)=>{const i=e.component=t.component;if(Le(t,e,n)){if(i.asyncDep&&!i.asyncResolved)return void H(i,e,n);i.next=e,i.update()}else e.el=t.el,i.vnode=e},j=(t,e,n,o,s,a,l)=>{const c=()=>{if(t.isMounted){let{next:e,bu:n,u:i,parent:o,vnode:u}=t;{const n=ve(t);if(n)return e&&(e.el=u.el,H(t,e,l)),void n.asyncDep.then((()=>{t.isUnmounted||c()}))}let h,d=e;0,me(t,!1),e?(e.el=u.el,H(t,e,l)):e=u,n&&(0,r.ir)(n),(h=e.props&&e.props.onVnodeBeforeUpdate)&&gn(h,o,e,u),me(t,!0);const p=Ee(t);0;const g=t.subTree;t.subTree=p,b(g,p,f(g.el),K(g),t,s,a),e.el=p.el,null===d&&Ne(t,p.el),i&&de(i,s),(h=e.props&&e.props.onVnodeUpdated)&&de((()=>gn(h,o,e,u)),s)}else{let i;const{el:l,props:c}=e,{bm:u,m:h,parent:d,root:f,type:p}=t,g=V(e);if(me(t,!1),u&&(0,r.ir)(u),!g&&(i=c&&c.onVnodeBeforeMount)&&gn(i,d,e),me(t,!0),l&&rt){const e=()=>{t.subTree=Ee(t),rt(l,t.subTree,t,s,null)};g&&p.__asyncHydrate?p.__asyncHydrate(l,t,e):e()}else{f.ce&&!1!==f.ce._def.shadowRoot&&f.ce._injectChildStyle(p);const i=t.subTree=Ee(t);0,b(null,i,n,o,t,s,a),e.el=i.el}if(h&&de(h,s),!g&&(i=c&&c.onVnodeMounted)){const t=e;de((()=>gn(i,d,t)),s)}(256&e.shapeFlag||d&&V(d.vnode)&&256&d.vnode.shapeFlag)&&t.a&&de(t.a,s),t.isMounted=!0,e=n=o=null}};t.scope.on();const u=t.effect=new i.qq(c);t.scope.off();const h=t.update=u.run.bind(u),d=t.job=u.runIfDirty.bind(u);d.i=t,d.id=t.uid,u.scheduler=()=>x(d),me(t,!0),h()},H=(t,e,n)=>{e.component=t;const r=t.vnode.props;t.vnode=e,t.next=null,Qt(t,e.props,r,n),ue(t,e.children,n),(0,i.Jd)(),w(t),(0,i.lk)()},W=(t,e,n,i,r,o,s,a,l=!1)=>{const c=t&&t.children,u=t?t.shapeFlag:0,h=e.children,{patchFlag:f,shapeFlag:p}=e;if(f>0){if(128&f)return void B(c,h,n,i,r,o,s,a,l);if(256&f)return void $(c,h,n,i,r,o,s,a,l)}8&p?(16&u&&J(c,r,o),h!==c&&d(n,h)):16&u?16&p?B(c,h,n,i,r,o,s,a,l):J(c,r,o,!0):(8&u&&d(n,""),16&p&&A(h,n,i,r,o,s,a,l))},$=(t,e,n,i,o,s,a,l,c)=>{t=t||r.Z6,e=e||r.Z6;const u=t.length,h=e.length,d=Math.min(u,h);let f;for(f=0;fh?J(t,o,s,!0,!1,d):A(e,n,i,o,s,a,l,c,d)},B=(t,e,n,i,o,s,a,l,c)=>{let u=0;const h=e.length;let d=t.length-1,f=h-1;while(u<=d&&u<=f){const i=t[u],r=e[u]=c?dn(e[u]):hn(e[u]);if(!tn(i,r))break;b(i,r,n,null,o,s,a,l,c),u++}while(u<=d&&u<=f){const i=t[d],r=e[f]=c?dn(e[f]):hn(e[f]);if(!tn(i,r))break;b(i,r,n,null,o,s,a,l,c),d--,f--}if(u>d){if(u<=f){const t=f+1,r=tf)while(u<=d)X(t[u],o,s,!0),u++;else{const p=u,g=u,m=new Map;for(u=g;u<=f;u++){const t=e[u]=c?dn(e[u]):hn(e[u]);null!=t.key&&m.set(t.key,u)}let x,y=0;const v=f-g+1;let w=!1,k=0;const _=new Array(v);for(u=0;u=v){X(i,o,s,!0);continue}let r;if(null!=i.key)r=m.get(i.key);else for(x=g;x<=f;x++)if(0===_[x-g]&&tn(i,e[x])){r=x;break}void 0===r?X(i,o,s,!0):(_[r-g]=u+1,r>=k?k=r:w=!0,b(i,e[r],n,null,o,s,a,l,c),y++)}const M=w?ye(_):r.Z6;for(x=M.length-1,u=v-1;u>=0;u--){const t=g+u,r=e[t],d=e[t+1],f=t+1{const{el:a,type:l,transition:c,children:u,shapeFlag:h}=t;if(6&h)return void q(t.component.subTree,e,n,i);if(128&h)return void t.suspense.move(e,n,i);if(64&h)return void l.move(t,e,n,nt);if(l===He){o(a,e,n);for(let t=0;tc.enter(a)),r);else{const{leave:i,delayLeave:r,afterLeave:l}=c,u=()=>{t.ctx.isUnmounted?s(a):o(a,e,n)},h=()=>{i(a,(()=>{u(),l&&l()}))};r?r(a,u,h):h()}else o(a,e,n)},X=(t,e,n,r=!1,o=!1)=>{const{type:s,props:a,ref:l,children:c,dynamicChildren:u,shapeFlag:h,patchFlag:d,dirs:f,cacheIndex:p}=t;if(-2===d&&(o=!1),null!=l&&((0,i.Jd)(),Y(l,null,n,t,!0),(0,i.lk)()),null!=p&&(e.renderCache[p]=void 0),256&h)return void e.ctx.deactivate(t);const g=1&h&&f,m=!V(t);let b;if(m&&(b=a&&a.onVnodeBeforeUnmount)&&gn(b,e,t),6&h)Q(t.component,n,r);else{if(128&h)return void t.suspense.unmount(n,r);g&&O(t,null,e,"beforeUnmount"),64&h?t.type.remove(t,e,n,nt,r):u&&!u.hasOnce&&(s!==He||d>0&&64&d)?J(u,e,n,!1,!0):(s===He&&384&d||!o&&16&h)&&J(c,e,n),r&&G(t)}(m&&(b=a&&a.onVnodeUnmounted)||g)&&de((()=>{b&&gn(b,e,t),g&&O(t,null,e,"unmounted")}),n)},G=t=>{const{type:e,el:n,anchor:i,transition:r}=t;if(e===He)return void Z(n,i);if(e===Be)return void S(t);const o=()=>{s(n),r&&!r.persisted&&r.afterLeave&&r.afterLeave()};if(1&t.shapeFlag&&r&&!r.persisted){const{leave:e,delayLeave:i}=r,s=()=>e(n,o);i?i(t.el,o,s):s()}else o()},Z=(t,e)=>{let n;while(t!==e)n=p(t),s(t),t=n;s(e)},Q=(t,e,n)=>{const{bum:i,scope:o,job:s,subTree:a,um:l,m:c,a:u,parent:h,slots:{__:d}}=t;we(c),we(u),i&&(0,r.ir)(i),h&&(0,r.kJ)(d)&&d.forEach((t=>{h.renderCache[t]=void 0})),o.stop(),s&&(s.flags|=8,X(a,t,e,n)),l&&de(l,e),de((()=>{t.isUnmounted=!0}),e),e&&e.pendingBranch&&!e.isUnmounted&&t.asyncDep&&!t.asyncResolved&&t.suspenseId===e.pendingId&&(e.deps--,0===e.deps&&e.resolve())},J=(t,e,n,i=!1,r=!1,o=0)=>{for(let s=o;s{if(6&t.shapeFlag)return K(t.component.subTree);if(128&t.shapeFlag)return t.suspense.next();const e=p(t.anchor||t.el),n=e&&e[P];return n?p(n):e};let tt=!1;const et=(t,e,n)=>{null==t?e._vnode&&X(e._vnode,null,null,!0):b(e._vnode||null,t,e,null,null,null,n),e._vnode=t,tt||(tt=!0,w(),k(),tt=!1)},nt={p:b,um:X,m:q,r:G,mt:N,mc:A,pc:W,pbc:R,n:K,o:t};let it,rt;return e&&([it,rt]=e(nt)),{render:et,hydrate:it,createApp:Bt(et,it)}}function ge({type:t,props:e},n){return"svg"===n&&"foreignObject"===t||"mathml"===n&&"annotation-xml"===t&&e&&e.encoding&&e.encoding.includes("html")?void 0:n}function me({effect:t,job:e},n){n?(t.flags|=32,e.flags|=4):(t.flags&=-33,e.flags&=-5)}function be(t,e){return(!t||t&&!t.pendingBranch)&&e&&!e.persisted}function xe(t,e,n=!1){const i=t.children,o=e.children;if((0,r.kJ)(i)&&(0,r.kJ)(o))for(let r=0;r>1,t[n[a]]0&&(e[i]=n[o-1]),n[o]=i)}}o=n.length,s=n[o-1];while(o-- >0)n[o]=s,s=e[s];return n}function ve(t){const e=t.subTree.component;if(e)return e.asyncDep&&!e.asyncResolved?e:ve(e)}function we(t){if(t)for(let e=0;e{{const t=Ut(ke);return t}};function Me(t,e,n){return Se(t,e,n)}function Se(t,e,n=r.kT){const{immediate:o,deep:a,flush:l,once:c}=n;const u=(0,r.l7)({},n);const h=e&&o||!e&&"post"!==l;let d;if(Cn)if("sync"===l){const t=_e();d=t.__watcherHandles||(t.__watcherHandles=[])}else if(!h){const t=()=>{};return t.stop=r.dG,t.resume=r.dG,t.pause=r.dG,t}const f=yn;u.call=(t,e,n)=>s(t,f,e,n);let p=!1;"post"===l?u.scheduler=t=>{de(t,f&&f.suspense)}:"sync"!==l&&(p=!0,u.scheduler=(t,e)=>{e?t():x(t)}),u.augmentJob=t=>{e&&(t.flags|=4),p&&(t.flags|=2,f&&(t.id=f.uid,t.i=f))};const g=(0,i.YP)(t,e,u);return Cn&&(d?d.push(g):h&&g()),g}function Te(t,e,n){const i=this.proxy,o=(0,r.HD)(t)?t.includes(".")?De(i,t):()=>i[t]:t.bind(i,i);let s;(0,r.mf)(e)?s=e:(s=e.handler,n=e);const a=_n(this),l=Se(o,s.bind(i),n);return a(),l}function De(t,e){const n=e.split(".");return()=>{let e=t;for(let t=0;t"modelValue"===e||"model-value"===e?t.modelModifiers:t[`${e}Modifiers`]||t[`${(0,r._A)(e)}Modifiers`]||t[`${(0,r.rs)(e)}Modifiers`];function Ae(t,e,...n){if(t.isUnmounted)return;const i=t.vnode.props||r.kT;let o=n;const a=e.startsWith("update:"),l=a&&Ce(i,e.slice(7));let c;l&&(l.trim&&(o=n.map((t=>(0,r.HD)(t)?t.trim():t))),l.number&&(o=n.map(r.h5)));let u=i[c=(0,r.hR)(e)]||i[c=(0,r.hR)((0,r._A)(e))];!u&&a&&(u=i[c=(0,r.hR)((0,r.rs)(e))]),u&&s(u,t,6,o);const h=i[c+"Once"];if(h){if(t.emitted){if(t.emitted[c])return}else t.emitted={};t.emitted[c]=!0,s(h,t,6,o)}}function Oe(t,e,n=!1){const i=e.emitsCache,o=i.get(t);if(void 0!==o)return o;const s=t.emits;let a={},l=!1;if(!(0,r.mf)(t)){const i=t=>{const n=Oe(t,e,!0);n&&(l=!0,(0,r.l7)(a,n))};!n&&e.mixins.length&&e.mixins.forEach(i),t.extends&&i(t.extends),t.mixins&&t.mixins.forEach(i)}return s||l?((0,r.kJ)(s)?s.forEach((t=>a[t]=null)):(0,r.l7)(a,s),(0,r.Kn)(t)&&i.set(t,a),a):((0,r.Kn)(t)&&i.set(t,null),null)}function Pe(t,e){return!(!t||!(0,r.F7)(e))&&(e=e.slice(2).replace(/Once$/,""),(0,r.RI)(t,e[0].toLowerCase()+e.slice(1))||(0,r.RI)(t,(0,r.rs)(e))||(0,r.RI)(t,e))}function Ee(t){const{type:e,vnode:n,proxy:i,withProxy:o,propsOptions:[s],slots:l,attrs:c,emit:u,render:h,renderCache:d,props:f,data:p,setupState:g,ctx:m,inheritAttrs:b}=t,x=D(t);let y,v;try{if(4&n.shapeFlag){const t=o||i,e=t;y=hn(h.call(e,t,d,f,g,p,m)),v=c}else{const t=e;0,y=hn(t.length>1?t(f,{attrs:c,slots:l,emit:u}):t(f,null)),v=e.props?c:Re(c)}}catch(k){Ye.length=0,a(k,t,1),y=on($e)}let w=y;if(v&&!1!==b){const t=Object.keys(v),{shapeFlag:e}=w;t.length&&7&e&&(s&&t.some(r.tR)&&(v=Ie(v,s)),w=ln(w,v,!1,!0))}return n.dirs&&(w=ln(w,null,!1,!0),w.dirs=w.dirs?w.dirs.concat(n.dirs):n.dirs),n.transition&&H(w,n.transition),y=w,D(x),y}const Re=t=>{let e;for(const n in t)("class"===n||"style"===n||(0,r.F7)(n))&&((e||(e={}))[n]=t[n]);return e},Ie=(t,e)=>{const n={};for(const i in t)(0,r.tR)(i)&&i.slice(9)in e||(n[i]=t[i]);return n};function Le(t,e,n){const{props:i,children:r,component:o}=t,{props:s,children:a,patchFlag:l}=e,c=o.emitsOptions;if(e.dirs||e.transition)return!0;if(!(n&&l>=0))return!(!r&&!a||a&&a.$stable)||i!==s&&(i?!s||ze(i,s,c):!!s);if(1024&l)return!0;if(16&l)return i?ze(i,s,c):!!s;if(8&l){const t=e.dynamicProps;for(let e=0;et.__isSuspense;function je(t,e){e&&e.pendingBranch?(0,r.kJ)(t)?e.effects.push(...t):e.effects.push(t):v(t)}const He=Symbol.for("v-fgt"),We=Symbol.for("v-txt"),$e=Symbol.for("v-cmt"),Be=Symbol.for("v-stc"),Ye=[];let Ve=null;function Ue(t=!1){Ye.push(Ve=t?null:[])}function qe(){Ye.pop(),Ve=Ye[Ye.length-1]||null}let Xe=1;function Ge(t,e=!1){Xe+=t,t<0&&Ve&&e&&(Ve.hasOnce=!0)}function Ze(t){return t.dynamicChildren=Xe>0?Ve||r.Z6:null,qe(),Xe>0&&Ve&&Ve.push(t),t}function Qe(t,e,n,i,r,o){return Ze(rn(t,e,n,i,r,o,!0))}function Je(t,e,n,i,r){return Ze(on(t,e,n,i,r,!0))}function Ke(t){return!!t&&!0===t.__v_isVNode}function tn(t,e){return t.type===e.type&&t.key===e.key}const en=({key:t})=>null!=t?t:null,nn=({ref:t,ref_key:e,ref_for:n})=>("number"===typeof t&&(t=""+t),null!=t?(0,r.HD)(t)||(0,i.dq)(t)||(0,r.mf)(t)?{i:S,r:t,k:e,f:!!n}:t:null);function rn(t,e=null,n=null,i=0,o=null,s=(t===He?0:1),a=!1,l=!1){const c={__v_isVNode:!0,__v_skip:!0,type:t,props:e,key:e&&en(e),ref:e&&nn(e),scopeId:T,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:s,patchFlag:i,dynamicProps:o,dynamicChildren:null,appContext:null,ctx:S};return l?(fn(c,n),128&s&&t.normalize(c)):n&&(c.shapeFlag|=(0,r.HD)(n)?8:16),Xe>0&&!a&&Ve&&(c.patchFlag>0||6&s)&&32!==c.patchFlag&&Ve.push(c),c}const on=sn;function sn(t,e=null,n=null,o=0,s=null,a=!1){if(t&&t!==pt||(t=$e),Ke(t)){const i=ln(t,e,!0);return n&&fn(i,n),Xe>0&&!a&&Ve&&(6&i.shapeFlag?Ve[Ve.indexOf(t)]=i:Ve.push(i)),i.patchFlag=-2,i}if(Nn(t)&&(t=t.__vccOpts),e){e=an(e);let{class:t,style:n}=e;t&&!(0,r.HD)(t)&&(e.class=(0,r.C_)(t)),(0,r.Kn)(n)&&((0,i.X3)(n)&&!(0,r.kJ)(n)&&(n=(0,r.l7)({},n)),e.style=(0,r.j5)(n))}const l=(0,r.HD)(t)?1:Fe(t)?128:E(t)?64:(0,r.Kn)(t)?4:(0,r.mf)(t)?2:0;return rn(t,e,n,o,s,l,a,!0)}function an(t){return t?(0,i.X3)(t)||Gt(t)?(0,r.l7)({},t):t:null}function ln(t,e,n=!1,i=!1){const{props:o,ref:s,patchFlag:a,children:l,transition:c}=t,u=e?pn(o||{},e):o,h={__v_isVNode:!0,__v_skip:!0,type:t.type,props:u,key:u&&en(u),ref:e&&e.ref?n&&s?(0,r.kJ)(s)?s.concat(nn(e)):[s,nn(e)]:nn(e):s,scopeId:t.scopeId,slotScopeIds:t.slotScopeIds,children:l,target:t.target,targetStart:t.targetStart,targetAnchor:t.targetAnchor,staticCount:t.staticCount,shapeFlag:t.shapeFlag,patchFlag:e&&t.type!==He?-1===a?16:16|a:a,dynamicProps:t.dynamicProps,dynamicChildren:t.dynamicChildren,appContext:t.appContext,dirs:t.dirs,transition:c,component:t.component,suspense:t.suspense,ssContent:t.ssContent&&ln(t.ssContent),ssFallback:t.ssFallback&&ln(t.ssFallback),placeholder:t.placeholder,el:t.el,anchor:t.anchor,ctx:t.ctx,ce:t.ce};return c&&i&&H(h,c.clone(h)),h}function cn(t=" ",e=0){return on(We,null,t,e)}function un(t="",e=!1){return e?(Ue(),Je($e,null,t)):on($e,null,t)}function hn(t){return null==t||"boolean"===typeof t?on($e):(0,r.kJ)(t)?on(He,null,t.slice()):Ke(t)?dn(t):on(We,null,String(t))}function dn(t){return null===t.el&&-1!==t.patchFlag||t.memo?t:ln(t)}function fn(t,e){let n=0;const{shapeFlag:i}=t;if(null==e)e=null;else if((0,r.kJ)(e))n=16;else if("object"===typeof e){if(65&i){const n=e.default;return void(n&&(n._c&&(n._d=!1),fn(t,n()),n._c&&(n._d=!0)))}{n=32;const i=e._;i||Gt(e)?3===i&&S&&(1===S.slots._?e._=1:(e._=2,t.patchFlag|=1024)):e._ctx=S}}else(0,r.mf)(e)?(e={default:e,_ctx:S},n=32):(e=String(e),64&i?(n=16,e=[cn(e)]):n=8);t.children=e,t.shapeFlag|=n}function pn(...t){const e={};for(let n=0;nyn||S;let wn,kn;{const t=(0,r.E9)(),e=(e,n)=>{let i;return(i=t[e])||(i=t[e]=[]),i.push(n),t=>{i.length>1?i.forEach((e=>e(t))):i[0](t)}};wn=e("__VUE_INSTANCE_SETTERS__",(t=>yn=t)),kn=e("__VUE_SSR_SETTERS__",(t=>Cn=t))}const _n=t=>{const e=yn;return wn(t),t.scope.on(),()=>{t.scope.off(),wn(e)}},Mn=()=>{yn&&yn.scope.off(),wn(null)};function Sn(t){return 4&t.vnode.shapeFlag}let Tn,Dn,Cn=!1;function An(t,e=!1,n=!1){e&&kn(e);const{props:i,children:r}=t.vnode,o=Sn(t);Zt(t,i,o,e),ce(t,r,n||e);const s=o?On(t,e):void 0;return e&&kn(!1),s}function On(t,e){const n=t.type;t.accessCache=Object.create(null),t.proxy=new Proxy(t.ctx,Mt);const{setup:s}=n;if(s){(0,i.Jd)();const n=t.setupContext=s.length>1?In(t):null,l=_n(t),c=o(s,t,0,[t.props,n]),u=(0,r.tI)(c);if((0,i.lk)(),l(),!u&&!t.sp||V(t)||B(t),u){if(c.then(Mn,Mn),e)return c.then((n=>{Pn(t,n,e)})).catch((e=>{a(e,t,0)}));t.asyncDep=c}else Pn(t,c,e)}else En(t,e)}function Pn(t,e,n){(0,r.mf)(e)?t.type.__ssrInlineRender?t.ssrRender=e:t.render=e:(0,r.Kn)(e)&&(t.setupState=(0,i.WL)(e)),En(t,n)}function En(t,e,n){const o=t.type;if(!t.render){if(!e&&Tn&&!o.render){const e=o.template||Pt(t).template;if(e){0;const{isCustomElement:n,compilerOptions:i}=t.appContext.config,{delimiters:s,compilerOptions:a}=o,l=(0,r.l7)((0,r.l7)({isCustomElement:n,delimiters:s},i),a);o.render=Tn(e,l)}}t.render=o.render||r.dG,Dn&&Dn(t)}{const e=_n(t);(0,i.Jd)();try{Dt(t)}finally{(0,i.lk)(),e()}}}const Rn={get(t,e){return(0,i.j)(t,"get",""),t[e]}};function In(t){const e=e=>{t.exposed=e||{}};return{attrs:new Proxy(t.attrs,Rn),slots:t.slots,emit:t.emit,expose:e}}function Ln(t){return t.exposed?t.exposeProxy||(t.exposeProxy=new Proxy((0,i.WL)((0,i.Xl)(t.exposed)),{get(e,n){return n in e?e[n]:n in kt?kt[n](t):void 0},has(t,e){return e in t||e in kt}})):t.proxy}function zn(t,e=!0){return(0,r.mf)(t)?t.displayName||t.name:t.name||e&&t.__name}function Nn(t){return(0,r.mf)(t)&&"__vccOpts"in t}const Fn=(t,e)=>{const n=(0,i.Fl)(t,e,Cn);return n};function jn(t,e,n){const i=arguments.length;return 2===i?(0,r.Kn)(e)&&!(0,r.kJ)(e)?Ke(e)?on(t,null,[e]):on(t,e):on(t,null,e):(i>3?n=Array.prototype.slice.call(arguments,2):3===i&&Ke(n)&&(n=[n]),on(t,e,n))}const Hn="3.5.18"},963:function(t,e,n){n.d(e,{D2:function(){return J},bM:function(){return V},iM:function(){return Z},ri:function(){return nt}});var i=n(252),r=n(577);n(262);
/**
* @vue/runtime-dom v3.5.18
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
let o;const s="undefined"!==typeof window&&window.trustedTypes;if(s)try{o=s.createPolicy("vue",{createHTML:t=>t})}catch(ot){}const a=o?t=>o.createHTML(t):t=>t,l="http://www.w3.org/2000/svg",c="http://www.w3.org/1998/Math/MathML",u="undefined"!==typeof document?document:null,h=u&&u.createElement("template"),d={insert:(t,e,n)=>{e.insertBefore(t,n||null)},remove:t=>{const e=t.parentNode;e&&e.removeChild(t)},createElement:(t,e,n,i)=>{const r="svg"===e?u.createElementNS(l,t):"mathml"===e?u.createElementNS(c,t):n?u.createElement(t,{is:n}):u.createElement(t);return"select"===t&&i&&null!=i.multiple&&r.setAttribute("multiple",i.multiple),r},createText:t=>u.createTextNode(t),createComment:t=>u.createComment(t),setText:(t,e)=>{t.nodeValue=e},setElementText:(t,e)=>{t.textContent=e},parentNode:t=>t.parentNode,nextSibling:t=>t.nextSibling,querySelector:t=>u.querySelector(t),setScopeId(t,e){t.setAttribute(e,"")},insertStaticContent(t,e,n,i,r,o){const s=n?n.previousSibling:e.lastChild;if(r&&(r===o||r.nextSibling)){while(1)if(e.insertBefore(r.cloneNode(!0),n),r===o||!(r=r.nextSibling))break}else{h.innerHTML=a("svg"===i?`${t} `:"mathml"===i?`${t} `:t);const r=h.content;if("svg"===i||"mathml"===i){const t=r.firstChild;while(t.firstChild)r.appendChild(t.firstChild);r.removeChild(t)}e.insertBefore(r,n)}return[s?s.nextSibling:e.firstChild,n?n.previousSibling:e.lastChild]}},f=Symbol("_vtc"),p={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String};i.nJ;function g(t,e,n){const i=t[f];i&&(e=(e?[e,...i]:[...i]).join(" ")),null==e?t.removeAttribute("class"):n?t.setAttribute("class",e):t.className=e}const m=Symbol("_vod"),b=Symbol("_vsh");const x=Symbol("");const y=/(^|;)\s*display\s*:/;function v(t,e,n){const i=t.style,o=(0,r.HD)(n);let s=!1;if(n&&!o){if(e)if((0,r.HD)(e))for(const t of e.split(";")){const e=t.slice(0,t.indexOf(":")).trim();null==n[e]&&k(i,e,"")}else for(const t in e)null==n[t]&&k(i,t,"");for(const t in n)"display"===t&&(s=!0),k(i,t,n[t])}else if(o){if(e!==n){const t=i[x];t&&(n+=";"+t),i.cssText=n,s=y.test(n)}}else e&&t.removeAttribute("style");m in t&&(t[m]=s?i.display:"",t[b]&&(i.display="none"))}const w=/\s*!important$/;function k(t,e,n){if((0,r.kJ)(n))n.forEach((n=>k(t,e,n)));else if(null==n&&(n=""),e.startsWith("--"))t.setProperty(e,n);else{const i=S(t,e);w.test(n)?t.setProperty((0,r.rs)(i),n.replace(w,""),"important"):t[i]=n}}const _=["Webkit","Moz","ms"],M={};function S(t,e){const n=M[e];if(n)return n;let i=(0,r._A)(e);if("filter"!==i&&i in t)return M[e]=i;i=(0,r.kC)(i);for(let r=0;r<_.length;r++){const n=_[r]+i;if(n in t)return M[e]=n}return e}const T="http://www.w3.org/1999/xlink";function D(t,e,n,i,o,s=(0,r.Pq)(e)){i&&e.startsWith("xlink:")?null==n?t.removeAttributeNS(T,e.slice(6,e.length)):t.setAttributeNS(T,e,n):null==n||s&&!(0,r.yA)(n)?t.removeAttribute(e):t.setAttribute(e,s?"":(0,r.yk)(n)?String(n):n)}function C(t,e,n,i,o){if("innerHTML"===e||"textContent"===e)return void(null!=n&&(t[e]="innerHTML"===e?a(n):n));const s=t.tagName;if("value"===e&&"PROGRESS"!==s&&!s.includes("-")){const i="OPTION"===s?t.getAttribute("value")||"":t.value,r=null==n?"checkbox"===t.type?"on":"":String(n);return i===r&&"_value"in t||(t.value=r),null==n&&t.removeAttribute(e),void(t._value=n)}let l=!1;if(""===n||null==n){const i=typeof t[e];"boolean"===i?n=(0,r.yA)(n):null==n&&"string"===i?(n="",l=!0):"number"===i&&(n=0,l=!0)}try{t[e]=n}catch(ot){0}l&&t.removeAttribute(o||e)}function A(t,e,n,i){t.addEventListener(e,n,i)}function O(t,e,n,i){t.removeEventListener(e,n,i)}const P=Symbol("_vei");function E(t,e,n,i,r=null){const o=t[P]||(t[P]={}),s=o[e];if(i&&s)s.value=i;else{const[n,a]=I(e);if(i){const s=o[e]=F(i,r);A(t,n,s,a)}else s&&(O(t,n,s,a),o[e]=void 0)}}const R=/(?:Once|Passive|Capture)$/;function I(t){let e;if(R.test(t)){let n;e={};while(n=t.match(R))t=t.slice(0,t.length-n[0].length),e[n[0].toLowerCase()]=!0}const n=":"===t[2]?t.slice(3):(0,r.rs)(t.slice(2));return[n,e]}let L=0;const z=Promise.resolve(),N=()=>L||(z.then((()=>L=0)),L=Date.now());function F(t,e){const n=t=>{if(t._vts){if(t._vts<=n.attached)return}else t._vts=Date.now();(0,i.$d)(j(t,n.value),e,5,[t])};return n.value=t,n.attached=N(),n}function j(t,e){if((0,r.kJ)(e)){const n=t.stopImmediatePropagation;return t.stopImmediatePropagation=()=>{n.call(t),t._stopped=!0},e.map((t=>e=>!e._stopped&&t&&t(e)))}return e}const H=t=>111===t.charCodeAt(0)&&110===t.charCodeAt(1)&&t.charCodeAt(2)>96&&t.charCodeAt(2)<123,W=(t,e,n,i,o,s)=>{const a="svg"===o;"class"===e?g(t,i,a):"style"===e?v(t,n,i):(0,r.F7)(e)?(0,r.tR)(e)||E(t,e,n,i,s):("."===e[0]?(e=e.slice(1),1):"^"===e[0]?(e=e.slice(1),0):$(t,e,i,a))?(C(t,e,i),t.tagName.includes("-")||"value"!==e&&"checked"!==e&&"selected"!==e||D(t,e,i,a,s,"value"!==e)):!t._isVueCE||!/[A-Z]/.test(e)&&(0,r.HD)(i)?("true-value"===e?t._trueValue=i:"false-value"===e&&(t._falseValue=i),D(t,e,i,a)):C(t,(0,r._A)(e),i,s,e)};function $(t,e,n,i){if(i)return"innerHTML"===e||"textContent"===e||!!(e in t&&H(e)&&(0,r.mf)(n));if("spellcheck"===e||"draggable"===e||"translate"===e||"autocorrect"===e)return!1;if("form"===e)return!1;if("list"===e&&"INPUT"===t.tagName)return!1;if("type"===e&&"TEXTAREA"===t.tagName)return!1;if("width"===e||"height"===e){const e=t.tagName;if("IMG"===e||"VIDEO"===e||"CANVAS"===e||"SOURCE"===e)return!1}return(!H(e)||!(0,r.HD)(n))&&e in t}
/*! #__NO_SIDE_EFFECTS__ */
"undefined"!==typeof HTMLElement&&HTMLElement;Symbol("_moveCb"),Symbol("_enterCb");const B=t=>{const e=t.props["onUpdate:modelValue"]||!1;return(0,r.kJ)(e)?t=>(0,r.ir)(e,t):e};const Y=Symbol("_assign");const V={deep:!0,created(t,{value:e,modifiers:{number:n}},o){const s=(0,r.DM)(e);A(t,"change",(()=>{const e=Array.prototype.filter.call(t.options,(t=>t.selected)).map((t=>n?(0,r.h5)(q(t)):q(t)));t[Y](t.multiple?s?new Set(e):e:e[0]),t._assigning=!0,(0,i.Y3)((()=>{t._assigning=!1}))})),t[Y]=B(o)},mounted(t,{value:e}){U(t,e)},beforeUpdate(t,e,n){t[Y]=B(n)},updated(t,{value:e}){t._assigning||U(t,e)}};function U(t,e){const n=t.multiple,i=(0,r.kJ)(e);if(!n||i||(0,r.DM)(e)){for(let o=0,s=t.options.length;oString(t)===String(a))):(0,r.hq)(e,a)>-1}else s.selected=e.has(a);else if((0,r.WV)(q(s),e))return void(t.selectedIndex!==o&&(t.selectedIndex=o))}n||-1===t.selectedIndex||(t.selectedIndex=-1)}}function q(t){return"_value"in t?t._value:t.value}const X=["ctrl","shift","alt","meta"],G={stop:t=>t.stopPropagation(),prevent:t=>t.preventDefault(),self:t=>t.target!==t.currentTarget,ctrl:t=>!t.ctrlKey,shift:t=>!t.shiftKey,alt:t=>!t.altKey,meta:t=>!t.metaKey,left:t=>"button"in t&&0!==t.button,middle:t=>"button"in t&&1!==t.button,right:t=>"button"in t&&2!==t.button,exact:(t,e)=>X.some((n=>t[`${n}Key`]&&!e.includes(n)))},Z=(t,e)=>{const n=t._withMods||(t._withMods={}),i=e.join(".");return n[i]||(n[i]=(n,...i)=>{for(let t=0;t{const n=t._withKeys||(t._withKeys={}),i=e.join(".");return n[i]||(n[i]=n=>{if(!("key"in n))return;const i=(0,r.rs)(n.key);return e.some((t=>t===i||Q[t]===i))?t(n):void 0})},K=(0,r.l7)({patchProp:W},d);let tt;function et(){return tt||(tt=(0,i.Us)(K))}const nt=(...t)=>{const e=et().createApp(...t);const{mount:n}=e;return e.mount=t=>{const i=rt(t);if(!i)return;const o=e._component;(0,r.mf)(o)||o.render||o.template||(o.template=i.innerHTML),1===i.nodeType&&(i.textContent="");const s=n(i,!1,it(i));return i instanceof Element&&(i.removeAttribute("v-cloak"),i.setAttribute("data-v-app","")),s},e};function it(t){return t instanceof SVGElement?"svg":"function"===typeof MathMLElement&&t instanceof MathMLElement?"mathml":void 0}function rt(t){if((0,r.HD)(t)){const e=document.querySelector(t);return e}return t}},577:function(t,e,n){
/**
* @vue/shared v3.5.18
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
/*! #__NO_SIDE_EFFECTS__ */
function i(t){const e=Object.create(null);for(const n of t.split(","))e[n]=1;return t=>t in e}n.d(e,{C_:function(){return Q},DM:function(){return m},E9:function(){return B},F7:function(){return l},Gg:function(){return A},HD:function(){return v},He:function(){return W},Kj:function(){return x},Kn:function(){return k},NO:function(){return a},Nj:function(){return j},Od:function(){return h},PO:function(){return D},Pq:function(){return K},RI:function(){return f},S0:function(){return C},W7:function(){return T},WV:function(){return nt},Z6:function(){return o},_A:function(){return E},_N:function(){return g},aU:function(){return N},dG:function(){return s},fY:function(){return i},h5:function(){return H},hR:function(){return z},hq:function(){return it},ir:function(){return F},j5:function(){return U},kC:function(){return L},kJ:function(){return p},kT:function(){return r},l7:function(){return u},mf:function(){return y},rs:function(){return I},tI:function(){return _},tR:function(){return c},yA:function(){return tt},yk:function(){return w},yl:function(){return V},zw:function(){return ot}});const r={},o=[],s=()=>{},a=()=>!1,l=t=>111===t.charCodeAt(0)&&110===t.charCodeAt(1)&&(t.charCodeAt(2)>122||t.charCodeAt(2)<97),c=t=>t.startsWith("onUpdate:"),u=Object.assign,h=(t,e)=>{const n=t.indexOf(e);n>-1&&t.splice(n,1)},d=Object.prototype.hasOwnProperty,f=(t,e)=>d.call(t,e),p=Array.isArray,g=t=>"[object Map]"===S(t),m=t=>"[object Set]"===S(t),b=t=>"[object Date]"===S(t),x=t=>"[object RegExp]"===S(t),y=t=>"function"===typeof t,v=t=>"string"===typeof t,w=t=>"symbol"===typeof t,k=t=>null!==t&&"object"===typeof t,_=t=>(k(t)||y(t))&&y(t.then)&&y(t.catch),M=Object.prototype.toString,S=t=>M.call(t),T=t=>S(t).slice(8,-1),D=t=>"[object Object]"===S(t),C=t=>v(t)&&"NaN"!==t&&"-"!==t[0]&&""+parseInt(t,10)===t,A=i(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),O=t=>{const e=Object.create(null);return n=>{const i=e[n];return i||(e[n]=t(n))}},P=/-(\w)/g,E=O((t=>t.replace(P,((t,e)=>e?e.toUpperCase():"")))),R=/\B([A-Z])/g,I=O((t=>t.replace(R,"-$1").toLowerCase())),L=O((t=>t.charAt(0).toUpperCase()+t.slice(1))),z=O((t=>{const e=t?`on${L(t)}`:"";return e})),N=(t,e)=>!Object.is(t,e),F=(t,...e)=>{for(let n=0;n{Object.defineProperty(t,e,{configurable:!0,enumerable:!1,writable:i,value:n})},H=t=>{const e=parseFloat(t);return isNaN(e)?t:e},W=t=>{const e=v(t)?Number(t):NaN;return isNaN(e)?t:e};let $;const B=()=>$||($="undefined"!==typeof globalThis?globalThis:"undefined"!==typeof self?self:"undefined"!==typeof window?window:"undefined"!==typeof n.g?n.g:{});const Y="Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol",V=i(Y);function U(t){if(p(t)){const e={};for(let n=0;n{if(t){const n=t.split(X);n.length>1&&(e[n[0].trim()]=n[1].trim())}})),e}function Q(t){let e="";if(v(t))e=t;else if(p(t))for(let n=0;nnt(t,e)))}const rt=t=>!(!t||!0!==t["__v_isRef"]),ot=t=>v(t)?t:null==t?"":p(t)||k(t)&&(t.toString===M||!y(t.toString))?rt(t)?ot(t.value):JSON.stringify(t,st,2):String(t),st=(t,e)=>rt(e)?st(t,e.value):g(e)?{[`Map(${e.size})`]:[...e.entries()].reduce(((t,[e,n],i)=>(t[at(e,i)+" =>"]=n,t)),{})}:m(e)?{[`Set(${e.size})`]:[...e.values()].map((t=>at(t)))}:w(e)?at(e):!k(e)||p(e)||D(e)?e:String(e),at=(t,e="")=>{var n;return w(t)?`Symbol(${null!=(n=t.description)?n:e})`:t}},264:function(t,e,n){n.d(e,{Z:function(){return h}});var i=n(252);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const r=t=>t.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),o=t=>t.replace(/^([A-Z])|[\s-_]+(\w)/g,((t,e,n)=>n?n.toUpperCase():e.toLowerCase())),s=t=>{const e=o(t);return e.charAt(0).toUpperCase()+e.slice(1)},a=(...t)=>t.filter(((t,e,n)=>Boolean(t)&&""!==t.trim()&&n.indexOf(t)===e)).join(" ").trim(),l=t=>""===t;
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
var c={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
const u=({name:t,iconNode:e,absoluteStrokeWidth:n,"absolute-stroke-width":o,strokeWidth:u,"stroke-width":h,size:d=c.width,color:f=c.stroke,...p},{slots:g})=>(0,i.h)("svg",{...c,...p,width:d,height:d,stroke:f,"stroke-width":l(n)||l(o)||!0===n||!0===o?24*Number(u||h||c["stroke-width"])/Number(d):u||h||c["stroke-width"],class:a("lucide",p.class,...t?[`lucide-${r(s(t))}-icon`,`lucide-${r(t)}`]:["lucide-icon"])},[...e.map((t=>(0,i.h)(...t))),...g.default?[g.default()]:[]]),h=(t,e)=>(n,{slots:r,attrs:o})=>(0,i.h)(u,{...o,...n,iconNode:e,name:t},r)},793:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("activity",[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]])},318:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("arrow-left",[["path",{d:"m12 19-7-7 7-7",key:"1l729n"}],["path",{d:"M19 12H5",key:"x3x0zl"}]])},368:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("check",[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]])},485:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("chevron-down",[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]])},372:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("chevron-left",[["path",{d:"m15 18-6-6 6-6",key:"1wnfg3"}]])},981:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("chevron-right",[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]])},893:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("chevron-up",[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]])},146:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("circle-alert",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]])},141:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("circle-arrow-down",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 8v8",key:"napkw2"}],["path",{d:"m8 12 4 4 4-4",key:"k98ssh"}]])},779:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("circle-arrow-up",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m16 12-4-4-4 4",key:"177agl"}],["path",{d:"M12 16V8",key:"1sbj14"}]])},89:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("circle-check-big",[["path",{d:"M21.801 10A10 10 0 1 1 17 3.335",key:"yps3ct"}],["path",{d:"m9 11 3 3L22 4",key:"1pflzl"}]])},478:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("circle-play",[["path",{d:"M9 9.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997A1 1 0 0 1 9 14.996z",key:"kmsa83"}],["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]])},691:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("circle-x",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m15 9-6 6",key:"1uzhvr"}],["path",{d:"m9 9 6 6",key:"z0biqf"}]])},337:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("circle",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]])},293:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("clock",[["path",{d:"M12 6v6l4 2",key:"mmk7yg"}],["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]])},322:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("download",[["path",{d:"M12 15V3",key:"m9g1x1"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["path",{d:"m7 10 5 5 5-5",key:"brsn70"}]])},5:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("info",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 16v-4",key:"1dtifu"}],["path",{d:"M12 8h.01",key:"e9boi3"}]])},135:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("log-in",[["path",{d:"m10 17 5-5-5-5",key:"1bsop3"}],["path",{d:"M15 12H3",key:"6jk70r"}],["path",{d:"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4",key:"u53s6r"}]])},507:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("menu",[["path",{d:"M4 12h16",key:"1lakjw"}],["path",{d:"M4 18h16",key:"19g7jn"}],["path",{d:"M4 6h16",key:"1o0s65"}]])},679:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("moon",[["path",{d:"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401",key:"kfwtm"}]])},167:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("pause",[["rect",{x:"14",y:"3",width:"5",height:"18",rx:"1",key:"kaeet6"}],["rect",{x:"5",y:"3",width:"5",height:"18",rx:"1",key:"1wsw3u"}]])},254:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("refresh-cw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]])},399:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("rotate-ccw",[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]])},275:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("search",[["path",{d:"m21 21-4.34-4.34",key:"14j7rj"}],["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}]])},740:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("settings",[["path",{d:"M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915",key:"1i5ecw"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]])},469:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("skip-forward",[["path",{d:"M21 4v16",key:"7j8fe9"}],["path",{d:"M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z",key:"zs4d6"}]])},789:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("sun",[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]])},138:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("timer",[["line",{x1:"10",x2:"14",y1:"2",y2:"2",key:"14vaq8"}],["line",{x1:"12",x2:"15",y1:"14",y2:"11",key:"17fdiu"}],["circle",{cx:"12",cy:"14",r:"8",key:"1e1u0o"}]])},446:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("triangle-alert",[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",key:"wmoenq"}],["path",{d:"M12 9v4",key:"juzpu7"}],["path",{d:"M12 17h.01",key:"p32p05"}]])},970:function(t,e,n){n.d(e,{Z:function(){return r}});var i=n(264);
/**
* @license lucide-vue-next v0.539.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const r=(0,i.Z)("x",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]])},744:function(t,e){e.Z=(t,e)=>{const n=t.__vccOpts||t;for(const[i,r]of e)n[i]=r;return n}},148:function(t,e,n){n.d(e,{De:function(){return Vn},Dx:function(){return Xn},FB:function(){return Ii},FK:function(){return c},Gu:function(){return zn},IQ:function(){return N},ST:function(){return I},W_:function(){return Rt},f$:function(){return _i},jI:function(){return R},jn:function(){return nn},kL:function(){return $e},od:function(){return on},u:function(){return pi},uw:function(){return yi}});var i=n(411);
/*!
* Chart.js v4.5.1
* https://www.chartjs.org
* (c) 2025 Chart.js Contributors
* Released under the MIT License
*/class r{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,n,i){const r=e.listeners[i],o=e.duration;r.forEach((i=>i({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(n-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=i.r.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((n,i)=>{if(!n.running||!n.items.length)return;const r=n.items;let o,s=r.length-1,a=!1;for(;s>=0;--s)o=r[s],o._active?(o._total>n.duration&&(n.duration=o._total),o.tick(t),a=!0):(r[s]=r[r.length-1],r.pop());a&&(i.draw(),this._notify(i,n,t,"progress")),r.length||(n.running=!1,this._notify(i,n,t,"complete"),n.initial=!1),e+=r.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let n=e.get(t);return n||(n={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,n)),n}listen(t,e,n){this._getAnims(t).listeners[e].push(n)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const n=e.items;let i=n.length-1;for(;i>=0;--i)n[i].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var o=new r;const s="transparent",a={boolean(t,e,n){return n>.5?e:t},color(t,e,n){const r=(0,i.c)(t||s),o=r.valid&&(0,i.c)(e||s);return o&&o.valid?o.mix(r,n).hexString():e},number(t,e,n){return t+(e-t)*n}};class l{constructor(t,e,n,r){const o=e[n];r=(0,i.a)([t.to,r,o,t.from]);const s=(0,i.a)([t.from,o,r]);this._active=!0,this._fn=t.fn||a[t.type||typeof s],this._easing=i.e[t.easing]||i.e.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=n,this._from=s,this._to=r,this._promises=void 0}active(){return this._active}update(t,e,n){if(this._active){this._notify(!1);const r=this._target[this._prop],o=n-this._start,s=this._duration-o;this._start=n,this._duration=Math.floor(Math.max(s,t.duration)),this._total+=o,this._loop=!!t.loop,this._to=(0,i.a)([t.to,e,r,t.from]),this._from=(0,i.a)([t.from,r,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,n=this._duration,i=this._prop,r=this._from,o=this._loop,s=this._to;let a;if(this._active=r!==s&&(o||e1?2-a:a,a=this._easing(Math.min(1,Math.max(0,a))),this._target[i]=this._fn(r,s,a))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,n)=>{t.push({res:e,rej:n})}))}_notify(t){const e=t?"res":"rej",n=this._promises||[];for(let i=0;i{const o=t[r];if(!(0,i.i)(o))return;const s={};for(const t of e)s[t]=o[t];((0,i.b)(o.properties)&&o.properties||[r]).forEach((t=>{t!==r&&n.has(t)||n.set(t,s)}))}))}_animateOptions(t,e){const n=e.options,i=h(t,n);if(!i)return[];const r=this._createAnimations(i,n);return n.$shared&&u(t.options.$animations,n).then((()=>{t.options=n}),(()=>{})),r}_createAnimations(t,e){const n=this._properties,i=[],r=t.$animations||(t.$animations={}),o=Object.keys(e),s=Date.now();let a;for(a=o.length-1;a>=0;--a){const c=o[a];if("$"===c.charAt(0))continue;if("options"===c){i.push(...this._animateOptions(t,e));continue}const u=e[c];let h=r[c];const d=n.get(c);if(h){if(d&&h.active()){h.update(d,u,s);continue}h.cancel()}d&&d.duration?(r[c]=h=new l(d,t,c,u),i.push(h)):t[c]=u}return i}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const n=this._createAnimations(t,e);return n.length?(o.add(this._chart,n),!0):void 0}}function u(t,e){const n=[],i=Object.keys(e);for(let r=0;r0||!n&&e<0)return r.index}return null}function _(t,e){const{chart:n,_cachedMeta:i}=t,r=n._stacks||(n._stacks={}),{iScale:o,vScale:s,index:a}=i,l=o.axis,c=s.axis,u=y(o,s,i),h=e.length;let d;for(let f=0;fn[t].axis===e)).shift()}function S(t,e){return(0,i.j)(t,{active:!1,dataset:void 0,datasetIndex:e,index:e,mode:"default",type:"dataset"})}function T(t,e,n){return(0,i.j)(t,{active:!1,dataIndex:e,parsed:void 0,raw:void 0,element:n,index:e,mode:"default",type:"data"})}function D(t,e){const n=t.controller.index,i=t.vScale&&t.vScale.axis;if(i){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[i]||void 0===e[i][n])return;delete e[i][n],void 0!==e[i]._visualValues&&void 0!==e[i]._visualValues[n]&&delete e[i]._visualValues[n]}}}const C=t=>"reset"===t||"none"===t,A=(t,e)=>e?t:Object.assign({},t),O=(t,e,n)=>t&&!e.hidden&&e._stacked&&{keys:g(n,!0),values:null};class P{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=x(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&D(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,n=this.getDataset(),r=(t,e,n,i)=>"x"===t?e:"r"===t?i:n,o=e.xAxisID=(0,i.v)(n.xAxisID,M(t,"x")),s=e.yAxisID=(0,i.v)(n.yAxisID,M(t,"y")),a=e.rAxisID=(0,i.v)(n.rAxisID,M(t,"r")),l=e.indexAxis,c=e.iAxisID=r(l,o,s,a),u=e.vAxisID=r(l,s,o,a);e.xScale=this.getScaleForId(o),e.yScale=this.getScaleForId(s),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(c),e.vScale=this.getScaleForId(u)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&(0,i.u)(this._data,this),t._stacked&&D(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),n=this._data;if((0,i.i)(e)){const t=this._cachedMeta;this._data=b(e,t)}else if(n!==e){if(n){(0,i.u)(n,this);const t=this._cachedMeta;D(t),t._parsed=[]}e&&Object.isExtensible(e)&&(0,i.l)(e,this),this._syncList=[],this._data=e}}addElements(){const t=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(t.dataset=new this.datasetElementType)}buildOrUpdateElements(t){const e=this._cachedMeta,n=this.getDataset();let i=!1;this._dataCheck();const r=e._stacked;e._stacked=x(e.vScale,e),e.stack!==n.stack&&(i=!0,D(e),e.stack=n.stack),this._resyncElements(t),(i||r!==e._stacked)&&(_(this,e._parsed),e._stacked=x(e.vScale,e))}configure(){const t=this.chart.config,e=t.datasetScopeKeys(this._type),n=t.getOptionScopes(this.getDataset(),e,!0);this.options=t.createResolver(n,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(t,e){const{_cachedMeta:n,_data:r}=this,{iScale:o,_stacked:s}=n,a=o.axis;let l,c,u,h=0===t&&e===r.length||n._sorted,d=t>0&&n._parsed[t-1];if(!1===this._parsing)n._parsed=r,n._sorted=!0,u=r;else{u=(0,i.b)(r[t])?this.parseArrayData(n,r,t,e):(0,i.i)(r[t])?this.parseObjectData(n,r,t,e):this.parsePrimitiveData(n,r,t,e);const o=()=>null===c[a]||d&&c[a]e||h=0;--d)if(!p()){this.updateRangeFromParsed(c,t,f,l);break}return c}getAllParsedValues(t){const e=this._cachedMeta._parsed,n=[];let r,o,s;for(r=0,o=e.length;r=0&&tthis.getContext(n,r,e),g=c.resolveNamedOptions(d,f,p,h);return g.$shared&&(g.$shared=l,o[s]=Object.freeze(A(g,l))),g}_resolveAnimations(t,e,n){const i=this.chart,r=this._cachedDataOpts,o=`animation-${e}`,s=r[o];if(s)return s;let a;if(!1!==i.options.animation){const i=this.chart.config,r=i.datasetAnimationScopeKeys(this._type,e),o=i.getOptionScopes(this.getDataset(),r);a=i.createResolver(o,this.getContext(t,n,e))}const l=new c(i,a&&a.animations);return a&&a._cacheable&&(r[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||C(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const n=this.resolveDataElementOptions(t,e),i=this._sharedOptions,r=this.getSharedOptions(n),o=this.includeOptions(e,r)||r!==i;return this.updateSharedOptions(r,e,n),{sharedOptions:r,includeOptions:o}}updateElement(t,e,n,i){C(i)?Object.assign(t,n):this._resolveAnimations(e,i).update(t,n)}updateSharedOptions(t,e,n){t&&!C(e)&&this._resolveAnimations(void 0,e).update(t,n)}_setStyle(t,e,n,i){t.active=i;const r=this.getStyle(e,i);this._resolveAnimations(e,n,i).update(t,{options:!i&&this.getSharedOptions(r)||r})}removeHoverStyle(t,e,n){this._setStyle(t,n,"active",!1)}setHoverStyle(t,e,n){this._setStyle(t,n,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,n=this._cachedMeta.data;for(const[s,a,l]of this._syncList)this[s](a,l);this._syncList=[];const i=n.length,r=e.length,o=Math.min(r,i);o&&this.parse(0,o),r>i?this._insertElements(i,r-i,t):r{for(t.length+=e,s=t.length-1;s>=o;s--)t[s]=t[s-e]};for(a(r),s=t;s(0,i.p)(t,l,c,!0)?1:Math.max(e,e*n,r,r*n),g=(t,e,r)=>(0,i.p)(t,l,c,!0)?-1:Math.min(e,e*n,r,r*n),m=p(0,u,d),b=p(i.H,h,f),x=g(i.P,u,d),y=g(i.P+i.H,h,f);r=(m-x)/2,o=(b-y)/2,s=-(m+x)/2,a=-(b+y)/2}return{ratioX:r,ratioY:o,offsetX:s,offsetY:a}}class R extends P{static id="doughnut";static defaults={datasetElementType:!1,dataElementType:"arc",animation:{animateRotate:!0,animateScale:!1},animations:{numbers:{type:"number",properties:["circumference","endAngle","innerRadius","outerRadius","startAngle","x","y","offset","borderWidth","spacing"]}},cutout:"50%",rotation:0,circumference:360,radius:"100%",spacing:0,indexAxis:"r"};static descriptors={_scriptable:t=>"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data,{labels:{pointStyle:n,textAlign:i,color:r,useBorderRadius:o,borderRadius:s}}=t.legend.options;return e.labels.length&&e.datasets.length?e.labels.map(((e,a)=>{const l=t.getDatasetMeta(0),c=l.controller.getStyle(a);return{text:e,fillStyle:c.backgroundColor,fontColor:r,hidden:!t.getDataVisibility(a),lineDash:c.borderDash,lineDashOffset:c.borderDashOffset,lineJoin:c.borderJoinStyle,lineWidth:c.borderWidth,strokeStyle:c.borderColor,textAlign:i,pointStyle:n,borderRadius:o&&(s||c.borderRadius),index:a}})):[]}},onClick(t,e,n){n.chart.toggleDataVisibility(e.index),n.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const n=this.getDataset().data,r=this._cachedMeta;if(!1===this._parsing)r._parsed=n;else{let o,s,a=t=>+n[t];if((0,i.i)(n[t])){const{key:t="value"}=this._parsing;a=e=>+(0,i.f)(n[e],t)}for(o=t,s=t+e;o0&&!isNaN(t)?i.T*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,n=this.chart,r=n.data.labels||[],o=(0,i.o)(e._parsed[t],n.options.locale);return{label:r[t]||"",value:o}}getMaxBorderWidth(t){let e=0;const n=this.chart;let i,r,o,s,a;if(!t)for(i=0,r=n.data.datasets.length;i0&&this.getParsed(e-1);for(let w=0;w=x){p.skip=!0;continue}const y=this.getParsed(w),k=(0,i.k)(y[f]),_=p[d]=s.getPixelForValue(y[d],w),M=p[f]=o||k?a.getBasePixel():a.getPixelForValue(l?this.applyStack(a,y,l):y[f],w);p.skip=isNaN(_)||isNaN(M)||k,p.stop=w>0&&Math.abs(y[d]-v[d])>m,g&&(p.parsed=y,p.raw=c.data[w]),h&&(p.options=u||this.resolveDataElementOptions(w,n.active?"active":r)),b||this.updateElement(n,w,p,r),v=y}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,n=e.options&&e.options.borderWidth||0,i=t.data||[];if(!i.length)return n;const r=i[0].size(this.resolveDataElementOptions(0)),o=i[i.length-1].size(this.resolveDataElementOptions(i.length-1));return Math.max(n,r,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}}function L(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class z{static override(t){Object.assign(z.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return L()}parse(){return L()}format(){return L()}add(){return L()}diff(){return L()}startOf(){return L()}endOf(){return L()}}var N={_date:z};function F(t,e,n,r){const{controller:o,data:s,_sorted:a}=t,l=o._cachedMeta.iScale,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null;if(l&&e===l.axis&&"r"!==e&&a&&s.length){const a=l._reversePixels?i.A:i.B;if(!r){const r=a(s,e,n);if(c){const{vScale:e}=o._cachedMeta,{_parsed:n}=t,s=n.slice(0,r.lo+1).reverse().findIndex((t=>!(0,i.k)(t[e.axis])));r.lo-=Math.max(0,s);const a=n.slice(r.hi).findIndex((t=>!(0,i.k)(t[e.axis])));r.hi+=Math.max(0,a)}return r}if(o._sharedOptions){const t=s[0],i="function"===typeof t.getRange&&t.getRange(e);if(i){const t=a(s,e,n-i),r=a(s,e,n+i);return{lo:t.lo,hi:r.hi}}}}return{lo:0,hi:s.length-1}}function j(t,e,n,i,r){const o=t.getSortedVisibleDatasetMetas(),s=n[e];for(let a=0,l=o.length;a{t[s]&&t[s](e[n],r)&&(o.push({element:t,datasetIndex:i,index:l}),a=a||t.inRange(e.x,e.y,r))})),i&&!a?[]:o}var U={evaluateInteractionItems:j,modes:{index(t,e,n,r){const o=(0,i.z)(e,t),s=n.axis||"x",a=n.includeInvisible||!1,l=n.intersect?W(t,o,s,r,a):Y(t,o,s,!1,r,a),c=[];return l.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=l[0].index,n=t.data[e];n&&!n.skip&&c.push({element:n,datasetIndex:t.index,index:e})})),c):[]},dataset(t,e,n,r){const o=(0,i.z)(e,t),s=n.axis||"xy",a=n.includeInvisible||!1;let l=n.intersect?W(t,o,s,r,a):Y(t,o,s,!1,r,a);if(l.length>0){const e=l[0].datasetIndex,n=t.getDatasetMeta(e).data;l=[];for(let t=0;tt.pos===e))}function G(t,e){return t.filter((t=>-1===q.indexOf(t.pos)&&t.box.axis===e))}function Z(t,e){return t.sort(((t,n)=>{const i=e?n:t,r=e?t:n;return i.weight===r.weight?i.index-r.index:i.weight-r.weight}))}function Q(t){const e=[];let n,i,r,o,s,a;for(n=0,i=(t||[]).length;nt.box.fullSize)),!0),i=Z(X(e,"left"),!0),r=Z(X(e,"right")),o=Z(X(e,"top"),!0),s=Z(X(e,"bottom")),a=G(e,"x"),l=G(e,"y");return{fullSize:n,leftAndTop:i.concat(o),rightAndBottom:r.concat(l).concat(s).concat(a),chartArea:X(e,"chartArea"),vertical:i.concat(r).concat(l),horizontal:o.concat(s).concat(a)}}function et(t,e,n,i){return Math.max(t[n],e[n])+Math.max(t[i],e[i])}function nt(t,e){t.top=Math.max(t.top,e.top),t.left=Math.max(t.left,e.left),t.bottom=Math.max(t.bottom,e.bottom),t.right=Math.max(t.right,e.right)}function it(t,e,n,r){const{pos:o,box:s}=n,a=t.maxPadding;if(!(0,i.i)(o)){n.size&&(t[o]-=n.size);const e=r[n.stack]||{size:0,count:1};e.size=Math.max(e.size,n.horizontal?s.height:s.width),n.size=e.size/e.count,t[o]+=n.size}s.getPadding&&nt(a,s.getPadding());const l=Math.max(0,e.outerWidth-et(a,t,"left","right")),c=Math.max(0,e.outerHeight-et(a,t,"top","bottom")),u=l!==t.w,h=c!==t.h;return t.w=l,t.h=c,n.horizontal?{same:u,other:h}:{same:h,other:u}}function rt(t){const e=t.maxPadding;function n(n){const i=Math.max(e[n]-t[n],0);return t[n]+=i,i}t.y+=n("top"),t.x+=n("left"),n("right"),n("bottom")}function ot(t,e){const n=e.maxPadding;function i(t){const i={left:0,top:0,right:0,bottom:0};return t.forEach((t=>{i[t]=Math.max(e[t],n[t])})),i}return i(t?["left","right"]:["top","bottom"])}function st(t,e,n,i){const r=[];let o,s,a,l,c,u;for(o=0,s=t.length,c=0;o{"function"===typeof t.beforeLayout&&t.beforeLayout()}));const h=c.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:n,padding:o,availableWidth:s,availableHeight:a,vBoxMaxWidth:s/2/h,hBoxMaxHeight:a/2}),f=Object.assign({},o);nt(f,(0,i.E)(r));const p=Object.assign({maxPadding:f,w:s,h:a,x:o.left,y:o.top},o),g=K(c.concat(u),d);st(l.fullSize,p,d,g),st(c,p,d,g),st(u,p,d,g)&&st(c,p,d,g),rt(p),lt(l.leftAndTop,p,d,g),p.x+=p.w,p.y+=p.h,lt(l.rightAndBottom,p,d,g),t.chartArea={left:p.left,top:p.top,right:p.left+p.w,bottom:p.top+p.h,height:p.h,width:p.w},(0,i.F)(l.chartArea,(e=>{const n=e.box;Object.assign(n,t.chartArea),n.update(p.w,p.h,{left:0,top:0,right:0,bottom:0})}))}};class ut{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,n){}removeEventListener(t,e,n){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,n,i){return e=Math.max(0,e||t.width),n=n||t.height,{width:e,height:Math.max(0,i?Math.floor(e/i):n)}}isAttached(t){return!0}updateConfig(t){}}class ht extends ut{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const dt="$chartjs",ft={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},pt=t=>null===t||""===t;function gt(t,e){const n=t.style,r=t.getAttribute("height"),o=t.getAttribute("width");if(t[dt]={initial:{height:r,width:o,style:{display:n.display,height:n.height,width:n.width}}},n.display=n.display||"block",n.boxSizing=n.boxSizing||"border-box",pt(o)){const e=(0,i.J)(t,"width");void 0!==e&&(t.width=e)}if(pt(r))if(""===t.style.height)t.height=t.width/(e||2);else{const e=(0,i.J)(t,"height");void 0!==e&&(t.height=e)}return t}const mt=!!i.K&&{passive:!0};function bt(t,e,n){t&&t.addEventListener(e,n,mt)}function xt(t,e,n){t&&t.canvas&&t.canvas.removeEventListener(e,n,mt)}function yt(t,e){const n=ft[t.type]||t.type,{x:r,y:o}=(0,i.z)(t,e);return{type:n,chart:e,native:t,x:void 0!==r?r:null,y:void 0!==o?o:null}}function vt(t,e){for(const n of t)if(n===e||n.contains(e))return!0}function wt(t,e,n){const i=t.canvas,r=new MutationObserver((t=>{let e=!1;for(const n of t)e=e||vt(n.addedNodes,i),e=e&&!vt(n.removedNodes,i);e&&n()}));return r.observe(document,{childList:!0,subtree:!0}),r}function kt(t,e,n){const i=t.canvas,r=new MutationObserver((t=>{let e=!1;for(const n of t)e=e||vt(n.removedNodes,i),e=e&&!vt(n.addedNodes,i);e&&n()}));return r.observe(document,{childList:!0,subtree:!0}),r}const _t=new Map;let Mt=0;function St(){const t=window.devicePixelRatio;t!==Mt&&(Mt=t,_t.forEach(((e,n)=>{n.currentDevicePixelRatio!==t&&e()})))}function Tt(t,e){_t.size||window.addEventListener("resize",St),_t.set(t,e)}function Dt(t){_t.delete(t),_t.size||window.removeEventListener("resize",St)}function Ct(t,e,n){const r=t.canvas,o=r&&(0,i.I)(r);if(!o)return;const s=(0,i.L)(((t,e)=>{const i=o.clientWidth;n(t,e),i{const e=t[0],n=e.contentRect.width,i=e.contentRect.height;0===n&&0===i||s(n,i)}));return a.observe(o),Tt(t,s),a}function At(t,e,n){n&&n.disconnect(),"resize"===e&&Dt(t)}function Ot(t,e,n){const r=t.canvas,o=(0,i.L)((e=>{null!==t.ctx&&n(yt(e,t))}),t);return bt(r,e,o),o}class Pt extends ut{acquireContext(t,e){const n=t&&t.getContext&&t.getContext("2d");return n&&n.canvas===t?(gt(t,e),n):null}releaseContext(t){const e=t.canvas;if(!e[dt])return!1;const n=e[dt].initial;["height","width"].forEach((t=>{const r=n[t];(0,i.k)(r)?e.removeAttribute(t):e.setAttribute(t,r)}));const r=n.style||{};return Object.keys(r).forEach((t=>{e.style[t]=r[t]})),e.width=e.width,delete e[dt],!0}addEventListener(t,e,n){this.removeEventListener(t,e);const i=t.$proxies||(t.$proxies={}),r={attach:wt,detach:kt,resize:Ct},o=r[e]||Ot;i[e]=o(t,e,n)}removeEventListener(t,e){const n=t.$proxies||(t.$proxies={}),i=n[e];if(!i)return;const r={attach:At,detach:At,resize:At},o=r[e]||xt;o(t,e,i),n[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,n,r){return(0,i.G)(t,e,n,r)}isAttached(t){const e=t&&(0,i.I)(t);return!(!e||!e.isConnected)}}function Et(t){return!(0,i.M)()||"undefined"!==typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ht:Pt}class Rt{static defaults={};static defaultRoutes=void 0;x;y;active=!1;options;$animations;tooltipPosition(t){const{x:e,y:n}=this.getProps(["x","y"],t);return{x:e,y:n}}hasValue(){return(0,i.x)(this.x)&&(0,i.x)(this.y)}getProps(t,e){const n=this.$animations;if(!e||!n)return this;const i={};return t.forEach((t=>{i[t]=n[t]&&n[t].active()?n[t]._to:this[t]})),i}}function It(t,e){const n=t.options.ticks,r=Lt(t),o=Math.min(n.maxTicksLimit||r,r),s=n.major.enabled?Nt(e):[],a=s.length,l=s[0],c=s[a-1],u=[];if(a>o)return Ft(e,u,s,a/o),u;const h=zt(s,e,o);if(a>0){let t,n;const r=a>1?Math.round((c-l)/(a-1)):null;for(jt(e,u,h,(0,i.k)(r)?0:l-r,l),t=0,n=a-1;to)return t}return Math.max(o,1)}function Nt(t){const e=[];let n,i;for(n=0,i=t.length;n"left"===t?"right":"right"===t?"left":t,$t=(t,e,n)=>"top"===e||"left"===e?t[e]+n:t[e]-n,Bt=(t,e)=>Math.min(e||t,t);function Yt(t,e){const n=[],i=t.length/e,r=t.length;let o=0;for(;os+a)))return c}function Ut(t,e){(0,i.F)(t,(t=>{const n=t.gc,i=n.length/2;let r;if(i>e){for(r=0;rr?r:n,r=o&&n>r?n:r,{min:(0,i.O)(n,(0,i.O)(r,n)),max:(0,i.O)(r,(0,i.O)(n,r))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){const e=this._labelItems||(this._labelItems=this._computeLabelItems(t));return e}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){(0,i.Q)(this.options.beforeUpdate,[this])}update(t,e,n){const{beginAtZero:r,grace:o,ticks:s}=this.options,a=s.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=n=Object.assign({left:0,right:0,top:0,bottom:0},n),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+n.left+n.right:this.height+n.top+n.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=(0,i.R)(this,o,r),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const l=a=o||n<=1||!this.isHorizontal())return void(this.labelRotation=r);const u=this._getLabelSizes(),h=u.widest.width,d=u.highest.height,f=(0,i.S)(this.chart.width-h,0,this.maxWidth);s=t.offset?this.maxWidth/n:f/(n-1),h+6>s&&(s=f/(n-(t.offset?.5:1)),a=this.maxHeight-qt(t.grid)-e.padding-Xt(t.title,this.chart.options.font),l=Math.sqrt(h*h+d*d),c=(0,i.U)(Math.min(Math.asin((0,i.S)((u.highest.height+6)/s,-1,1)),Math.asin((0,i.S)(a/l,-1,1))-Math.asin((0,i.S)(d/l,-1,1)))),c=Math.max(r,Math.min(o,c))),this.labelRotation=c}afterCalculateLabelRotation(){(0,i.Q)(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){(0,i.Q)(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:n,title:r,grid:o}}=this,s=this._isVisible(),a=this.isHorizontal();if(s){const s=Xt(r,e.options.font);if(a?(t.width=this.maxWidth,t.height=qt(o)+s):(t.height=this.maxHeight,t.width=qt(o)+s),n.display&&this.ticks.length){const{first:e,last:r,widest:o,highest:s}=this._getLabelSizes(),l=2*n.padding,c=(0,i.t)(this.labelRotation),u=Math.cos(c),h=Math.sin(c);if(a){const e=n.mirror?0:h*o.width+u*s.height;t.height=Math.min(this.maxHeight,t.height+e+l)}else{const e=n.mirror?0:u*o.width+h*s.height;t.width=Math.min(this.maxWidth,t.width+e+l)}this._calculatePadding(e,r,h,u)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,n,i){const{ticks:{align:r,padding:o},position:s}=this.options,a=0!==this.labelRotation,l="top"!==s&&"x"===this.axis;if(this.isHorizontal()){const s=this.getPixelForTick(0)-this.left,c=this.right-this.getPixelForTick(this.ticks.length-1);let u=0,h=0;a?l?(u=i*t.width,h=n*e.height):(u=n*t.height,h=i*e.width):"start"===r?h=e.width:"end"===r?u=t.width:"inner"!==r&&(u=t.width/2,h=e.width/2),this.paddingLeft=Math.max((u-s+o)*this.width/(this.width-s),0),this.paddingRight=Math.max((h-c+o)*this.width/(this.width-c),0)}else{let n=e.height/2,i=t.height/2;"start"===r?(n=0,i=t.height):"end"===r&&(n=e.height,i=0),this.paddingTop=n+o,this.paddingBottom=i+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){(0,i.Q)(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,n;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,n=t.length;e({width:s[t]||0,height:a[t]||0});return{first:M(0),last:M(e-1),widest:M(k),highest:M(_),widths:s,heights:a}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return(0,i.W)(this._alignToPixels?(0,i.X)(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*r?a/n:l/r:l*r0}_computeGridLineItems(t){const e=this.axis,n=this.chart,r=this.options,{grid:o,position:s,border:a}=r,l=o.offset,c=this.isHorizontal(),u=this.ticks,h=u.length+(l?1:0),d=qt(o),f=[],p=a.setContext(this.getContext()),g=p.display?p.width:0,m=g/2,b=function(t){return(0,i.X)(n,t,g)};let x,y,v,w,k,_,M,S,T,D,C,A;if("top"===s)x=b(this.bottom),_=this.bottom-d,S=x-m,D=b(t.top)+m,A=t.bottom;else if("bottom"===s)x=b(this.top),D=t.top,A=b(t.bottom)-m,_=x+m,S=this.top+d;else if("left"===s)x=b(this.right),k=this.right-d,M=x-m,T=b(t.left)+m,C=t.right;else if("right"===s)x=b(this.left),T=t.left,C=b(t.right)-m,k=x+m,M=this.left+d;else if("x"===e){if("center"===s)x=b((t.top+t.bottom)/2+.5);else if((0,i.i)(s)){const t=Object.keys(s)[0],e=s[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,A=t.bottom,_=x+m,S=_+d}else if("y"===e){if("center"===s)x=b((t.left+t.right)/2);else if((0,i.i)(s)){const t=Object.keys(s)[0],e=s[t];x=b(this.chart.scales[t].getPixelForValue(e))}k=x-m,M=k-d,T=t.left,C=t.right}const O=(0,i.v)(r.ticks.maxTicksLimit,h),P=Math.max(1,Math.ceil(h/O));for(y=0;y0&&(s-=r/2);break}d={left:s,top:o,width:r+e.width,height:n+e.height,color:t.backdropColor}}m.push({label:v,font:S,textOffset:C,options:{rotation:g,color:n,strokeColor:l,strokeWidth:u,textAlign:f,textBaseline:A,translation:[w,k],backdrop:d}})}return m}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options,n=-(0,i.t)(this.labelRotation);if(n)return"top"===t?"left":"right";let r="center";return"start"===e.align?r="left":"end"===e.align?r="right":"inner"===e.align&&(r="inner"),r}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:n,mirror:i,padding:r}}=this.options,o=this._getLabelSizes(),s=t+r,a=o.widest.width;let l,c;return"left"===e?i?(c=this.right+r,"near"===n?l="left":"center"===n?(l="center",c+=a/2):(l="right",c+=a)):(c=this.right-s,"near"===n?l="right":"center"===n?(l="center",c-=a/2):(l="left",c=this.left)):"right"===e?i?(c=this.left+r,"near"===n?l="right":"center"===n?(l="center",c-=a/2):(l="left",c-=a)):(c=this.left+s,"near"===n?l="left":"center"===n?(l="center",c+=a/2):(l="right",c=this.right)):l="right",{textAlign:l,x:c}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:n,top:i,width:r,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(n,i,r,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const n=this.ticks,i=n.findIndex((e=>e.value===t));if(i>=0){const t=e.setContext(this.getContext(i));return t.lineWidth}return 0}drawGrid(t){const e=this.options.grid,n=this.ctx,i=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let r,o;const s=(t,e,i)=>{i.width&&i.color&&(n.save(),n.lineWidth=i.width,n.strokeStyle=i.color,n.setLineDash(i.borderDash||[]),n.lineDashOffset=i.borderDashOffset,n.beginPath(),n.moveTo(t.x,t.y),n.lineTo(e.x,e.y),n.stroke(),n.restore())};if(e.display)for(r=0,o=i.length;r{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:r,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),n=this.axis+"AxisID",i=[];let r,o;for(r=0,o=e.length;r{const r=n.split("."),o=r.pop(),s=[t].concat(r).join("."),a=e[n].split("."),l=a.pop(),c=a.join(".");i.d.route(s,o,c,l)}))}function ie(t){return"id"in t&&"defaults"in t}class re{constructor(){this.controllers=new te(P,"datasets",!0),this.elements=new te(Rt,"elements"),this.plugins=new te(Object,"plugins"),this.scales=new te(Kt,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,n){[...e].forEach((e=>{const r=n||this._getRegistryForType(e);n||r.isForType(e)||r===this.plugins&&e.id?this._exec(t,r,e):(0,i.F)(e,(e=>{const i=n||this._getRegistryForType(e);this._exec(t,i,e)}))}))}_exec(t,e,n){const r=(0,i.a5)(t);(0,i.Q)(n["before"+r],[],n),e[t](n),(0,i.Q)(n["after"+r],[],n)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(i(e,n),t,"stop"),this._notify(i(n,e),t,"start")}}function ae(t){const e={},n=[],i=Object.keys(oe.plugins.items);for(let o=0;o1&&pe(t[0].toLowerCase());if(e)return e}throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function be(t,e,n){if(n[e+"AxisID"]===t)return{axis:e}}function xe(t,e){if(e.data&&e.data.datasets){const n=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(n.length)return be(t,"x",n[0])||be(t,"y",n[0])}return{}}function ye(t,e){const n=i.a3[t.type]||{scales:{}},r=e.scales||{},o=he(t.type,e),s=Object.create(null);return Object.keys(r).forEach((e=>{const a=r[e];if(!(0,i.i)(a))return console.error(`Invalid scale configuration for scale: ${e}`);if(a._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=me(e,a,xe(e,t),i.d.scales[a.type]),c=fe(l,o),u=n.scales||{};s[e]=(0,i.ab)(Object.create(null),[{axis:l},a,u[l],u[c]])})),t.data.datasets.forEach((n=>{const o=n.type||t.type,a=n.indexAxis||he(o,e),l=i.a3[o]||{},c=l.scales||{};Object.keys(c).forEach((t=>{const e=de(t,a),o=n[e+"AxisID"]||e;s[o]=s[o]||Object.create(null),(0,i.ab)(s[o],[{axis:e},r[o],c[t]])}))})),Object.keys(s).forEach((t=>{const e=s[t];(0,i.ab)(e,[i.d.scales[e.type],i.d.scale])})),s}function ve(t){const e=t.options||(t.options={});e.plugins=(0,i.v)(e.plugins,{}),e.scales=ye(t,e)}function we(t){return t=t||{},t.datasets=t.datasets||[],t.labels=t.labels||[],t}function ke(t){return t=t||{},t.data=we(t.data),ve(t),t}const _e=new Map,Me=new Set;function Se(t,e){let n=_e.get(t);return n||(n=e(),_e.set(t,n),Me.add(n)),n}const Te=(t,e,n)=>{const r=(0,i.f)(e,n);void 0!==r&&t.add(r)};class De{constructor(t){this._config=ke(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=we(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),ve(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return Se(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return Se(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return Se(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id,n=this.type;return Se(`${n}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const n=this._scopeCache;let i=n.get(t);return i&&!e||(i=new Map,n.set(t,i)),i}getOptionScopes(t,e,n){const{options:r,type:o}=this,s=this._cachedScopes(t,n),a=s.get(e);if(a)return a;const l=new Set;e.forEach((e=>{t&&(l.add(t),e.forEach((e=>Te(l,t,e)))),e.forEach((t=>Te(l,r,t))),e.forEach((t=>Te(l,i.a3[o]||{},t))),e.forEach((t=>Te(l,i.d,t))),e.forEach((t=>Te(l,i.a6,t)))}));const c=Array.from(l);return 0===c.length&&c.push(Object.create(null)),Me.has(e)&&s.set(e,c),c}chartOptionScopes(){const{options:t,type:e}=this;return[t,i.a3[e]||{},i.d.datasets[e]||{},{type:e},i.d,i.a6]}resolveNamedOptions(t,e,n,r=[""]){const o={$shared:!0},{resolver:s,subPrefixes:a}=Ce(this._resolverCache,t,r);let l=s;if(Oe(s,e)){o.$shared=!1,n=(0,i.a7)(n)?n():n;const e=this.createResolver(t,n,a);l=(0,i.a8)(s,n,e)}for(const i of e)o[i]=l[i];return o}createResolver(t,e,n=[""],r){const{resolver:o}=Ce(this._resolverCache,t,n);return(0,i.i)(e)?(0,i.a8)(o,e,void 0,r):o}}function Ce(t,e,n){let r=t.get(e);r||(r=new Map,t.set(e,r));const o=n.join();let s=r.get(o);if(!s){const t=(0,i.a9)(e,n);s={resolver:t,subPrefixes:n.filter((t=>!t.toLowerCase().includes("hover")))},r.set(o,s)}return s}const Ae=t=>(0,i.i)(t)&&Object.getOwnPropertyNames(t).some((e=>(0,i.a7)(t[e])));function Oe(t,e){const{isScriptable:n,isIndexable:r}=(0,i.aa)(t);for(const o of e){const e=n(o),s=r(o),a=(s||e)&&t[o];if(e&&((0,i.a7)(a)||Ae(a))||s&&(0,i.b)(a))return!0}return!1}var Pe="4.5.1";const Ee=["top","bottom","left","right","chartArea"];function Re(t,e){return"top"===t||"bottom"===t||-1===Ee.indexOf(t)&&"x"===e}function Ie(t,e){return function(n,i){return n[t]===i[t]?n[e]-i[e]:n[t]-i[t]}}function Le(t){const e=t.chart,n=e.options.animation;e.notifyPlugins("afterRender"),(0,i.Q)(n&&n.onComplete,[t],e)}function ze(t){const e=t.chart,n=e.options.animation;(0,i.Q)(n&&n.onProgress,[t],e)}function Ne(t){return(0,i.M)()&&"string"===typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Fe={},je=t=>{const e=Ne(t);return Object.values(Fe).filter((t=>t.canvas===e)).pop()};function He(t,e,n){const i=Object.keys(t);for(const r of i){const i=+r;if(i>=e){const o=t[r];delete t[r],(n>0||i>e)&&(t[i+n]=o)}}}function We(t,e,n,i){return n&&"mouseout"!==t.type?i?e:t:null}class $e{static defaults=i.d;static instances=Fe;static overrides=i.a3;static registry=oe;static version=Pe;static getChart=je;static register(...t){oe.add(...t),Be()}static unregister(...t){oe.remove(...t),Be()}constructor(t,e){const n=this.config=new De(e),r=Ne(t),s=je(r);if(s)throw new Error("Canvas is already in use. Chart with ID '"+s.id+"' must be destroyed before the canvas with ID '"+s.canvas.id+"' can be reused.");const a=n.createResolver(n.chartOptionScopes(),this.getContext());this.platform=new(n.platform||Et(r)),this.platform.updateConfig(n);const l=this.platform.acquireContext(r,a.aspectRatio),c=l&&l.canvas,u=c&&c.height,h=c&&c.width;this.id=(0,i.ac)(),this.ctx=l,this.canvas=c,this.width=h,this.height=u,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new se,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=(0,i.ad)((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Fe[this.id]=this,l&&c?(o.listen(this,"complete",Le),o.listen(this,"progress",ze),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:n,height:r,_aspectRatio:o}=this;return(0,i.k)(t)?e&&o?o:r?n/r:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return oe}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():(0,i.ae)(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return(0,i.af)(this.canvas,this.ctx),this}stop(){return o.stop(this),this}resize(t,e){o.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const n=this.options,r=this.canvas,o=n.maintainAspectRatio&&this.aspectRatio,s=this.platform.getMaximumSize(r,t,e,o),a=n.devicePixelRatio||this.platform.getDevicePixelRatio(),l=this.width?"resize":"attach";this.width=s.width,this.height=s.height,this._aspectRatio=this.aspectRatio,(0,i.ae)(this,a,!0)&&(this.notifyPlugins("resize",{size:s}),(0,i.Q)(n.onResize,[this,s],this),this.attached&&this._doResize(l)&&this.render())}ensureScalesHaveIDs(){const t=this.options,e=t.scales||{};(0,i.F)(e,((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,n=this.scales,r=Object.keys(n).reduce(((t,e)=>(t[e]=!1,t)),{});let o=[];e&&(o=o.concat(Object.keys(e).map((t=>{const n=e[t],i=me(t,n),r="r"===i,o="x"===i;return{options:n,dposition:r?"chartArea":o?"bottom":"left",dtype:r?"radialLinear":o?"category":"linear"}})))),(0,i.F)(o,(e=>{const o=e.options,s=o.id,a=me(s,o),l=(0,i.v)(o.type,e.dtype);void 0!==o.position&&Re(o.position,a)===Re(e.dposition)||(o.position=e.dposition),r[s]=!0;let c=null;if(s in n&&n[s].type===l)c=n[s];else{const t=oe.getScale(l);c=new t({id:s,type:l,ctx:this.ctx,chart:this}),n[c.id]=c}c.init(o,t)})),(0,i.F)(r,((t,e)=>{t||delete n[e]})),(0,i.F)(n,(t=>{ct.configure(this,t,t.options),ct.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,n=t.length;if(t.sort(((t,e)=>t.index-e.index)),n>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,n)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(n)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let n,r;for(this._removeUnreferencedMetasets(),n=0,r=e.length;n{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const n=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),r=this._animationsDisabled=!n.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const o=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let s=0;for(let i=0,c=this.data.datasets.length;i{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Ie("z","_idx"));const{_active:a,_lastEvent:l}=this;l?this._eventHandler(l,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){(0,i.F)(this.scales,(t=>{ct.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),n=new Set(t.events);(0,i.ag)(e,n)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:n,start:i,count:r}of e){const e="_removeElements"===n?-r:r;He(t,i,e)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,n=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),r=n(0);for(let o=1;ot.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;ct.update(this,this.width,this.height,t);const e=this.chartArea,n=e.width<=0||e.height<=0;this._layers=[],(0,i.F)(this.boxes,(t=>{n&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,n={meta:t,index:t.index,cancelable:!0},r=(0,i.ah)(this,t);!1!==this.notifyPlugins("beforeDatasetDraw",n)&&(r&&(0,i.Y)(e,r),t.controller.draw(),r&&(0,i.$)(e),n.cancelable=!1,this.notifyPlugins("afterDatasetDraw",n))}isPointInArea(t){return(0,i.C)(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,n,i){const r=U.modes[e];return"function"===typeof r?r(this,t,n,i):[]}getDatasetMeta(t){const e=this.data.datasets[t],n=this._metasets;let i=n.filter((t=>t&&t._dataset===e)).pop();return i||(i={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},n.push(i)),i}getContext(){return this.$context||(this.$context=(0,i.j)(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const n=this.getDatasetMeta(t);return"boolean"===typeof n.hidden?!n.hidden:!e.hidden}setDatasetVisibility(t,e){const n=this.getDatasetMeta(t);n.hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,n){const r=n?"show":"hide",o=this.getDatasetMeta(t),s=o.controller._resolveAnimations(void 0,r);(0,i.h)(e)?(o.data[e].hidden=!n,this.update()):(this.setDatasetVisibility(t,n),s.update(o,{visible:n}),this.update((e=>e.datasetIndex===t?r:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),o.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,n,i),t[n]=i},r=(t,e,n)=>{t.offsetX=e,t.offsetY=n,this._eventHandler(t)};(0,i.F)(this.options.events,(t=>n(t,r)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,n=(n,i)=>{e.addEventListener(this,n,i),t[n]=i},i=(n,i)=>{t[n]&&(e.removeEventListener(this,n,i),delete t[n])},r=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const s=()=>{i("attach",s),this.attached=!0,this.resize(),n("resize",r),n("detach",o)};o=()=>{this.attached=!1,i("resize",r),this._stop(),this._resize(0,0),n("attach",s)},e.isAttached(this.canvas)?s():o()}unbindEvents(){(0,i.F)(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},(0,i.F)(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,n){const i=n?"set":"remove";let r,o,s,a;for("dataset"===e&&(r=this.getDatasetMeta(t[0].datasetIndex),r.controller["_"+i+"DatasetHoverStyle"]()),s=0,a=t.length;s{const n=this.getDatasetMeta(t);if(!n)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:n.data[e],index:e}})),r=!(0,i.ai)(n,e);r&&(this._active=n,this._lastEvent=null,this._updateHoverStyles(n,e))}notifyPlugins(t,e,n){return this._plugins.notify(this,t,e,n)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,n){const i=this.options.hover,r=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=r(e,t),s=n?t:r(t,e);o.length&&this.updateHoverStyle(o,i.mode,!1),s.length&&i.mode&&this.updateHoverStyle(s,i.mode,!0)}_eventHandler(t,e){const n={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},i=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",n,i))return;const r=this._handleEvent(t,e,n.inChartArea);return n.cancelable=!1,this.notifyPlugins("afterEvent",n,i),(r||n.changed)&&this.render(),this}_handleEvent(t,e,n){const{_active:r=[],options:o}=this,s=e,a=this._getActiveElements(t,r,n,s),l=(0,i.aj)(t),c=We(t,this._lastEvent,n,l);n&&(this._lastEvent=null,(0,i.Q)(o.onHover,[t,a,this],this),l&&(0,i.Q)(o.onClick,[t,a,this],this));const u=!(0,i.ai)(a,r);return(u||e)&&(this._active=a,this._updateHoverStyles(a,r,e)),this._lastEvent=c,u}_getActiveElements(t,e,n,i){if("mouseout"===t.type)return[];if(!n)return e;const r=this.options.hover;return this.getElementsAtEventForMode(t,r.mode,r,i)}}function Be(){return(0,i.F)($e.instances,(t=>t._plugins.invalidate()))}function Ye(t,e,n=e){t.lineCap=(0,i.v)(n.borderCapStyle,e.borderCapStyle),t.setLineDash((0,i.v)(n.borderDash,e.borderDash)),t.lineDashOffset=(0,i.v)(n.borderDashOffset,e.borderDashOffset),t.lineJoin=(0,i.v)(n.borderJoinStyle,e.borderJoinStyle),t.lineWidth=(0,i.v)(n.borderWidth,e.borderWidth),t.strokeStyle=(0,i.v)(n.borderColor,e.borderColor)}function Ve(t,e,n){t.lineTo(n.x,n.y)}function Ue(t){return t.stepped?i.at:t.tension||"monotone"===t.cubicInterpolationMode?i.au:Ve}function qe(t,e,n={}){const i=t.length,{start:r=0,end:o=i-1}=n,{start:s,end:a}=e,l=Math.max(r,s),c=Math.min(o,a),u=ra&&o>a;return{count:i,start:l,loop:e.loop,ilen:c(s+(c?a-t:t))%o,y=()=>{f!==p&&(t.lineTo(m,p),t.lineTo(m,f),t.lineTo(m,g))};for(l&&(h=r[x(0)],t.moveTo(h.x,h.y)),u=0;u<=a;++u){if(h=r[x(u)],h.skip)continue;const e=h.x,n=h.y,i=0|e;i===d?(np&&(p=n),m=(b*m+e)/++b):(y(),t.lineTo(e,n),d=i,b=0,f=p=n),g=n}y()}function Ze(t){const e=t.options,n=e.borderDash&&e.borderDash.length,i=!t._decimated&&!t._loop&&!e.tension&&"monotone"!==e.cubicInterpolationMode&&!e.stepped&&!n;return i?Ge:Xe}function Qe(t){return t.stepped?i.aq:t.tension||"monotone"===t.cubicInterpolationMode?i.ar:i.as}function Je(t,e,n,i){let r=e._path;r||(r=e._path=new Path2D,e.path(r,n,i)&&r.closePath()),Ye(t,e.options),t.stroke(r)}function Ke(t,e,n,i){const{segments:r,options:o}=e,s=Ze(e);for(const a of r)Ye(t,o,a.style),t.beginPath(),s(t,e,a,{start:n,end:n+i-1})&&t.closePath(),t.stroke()}const tn="function"===typeof Path2D;function en(t,e,n,i){tn&&!e.options.segment?Je(t,e,n,i):Ke(t,e,n,i)}class nn extends Rt{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const n=this.options;if((n.tension||"monotone"===n.cubicInterpolationMode)&&!n.stepped&&!this._pointsUpdated){const r=n.spanGaps?this._loop:this._fullLoop;(0,i.an)(this._points,n,t,r,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=(0,i.ao)(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,n=t.length;return n&&e[t[n-1].end]}interpolate(t,e){const n=this.options,r=t[e],o=this.points,s=(0,i.ap)(this,{property:e,start:r,end:r});if(!s.length)return;const a=[],l=Qe(n);let c,u;for(c=0,u=s.length;c{e=cn(t,e,r);const s=r[t],a=r[e];null!==i?(o.push({x:s.x,y:i}),o.push({x:a.x,y:i})):null!==n&&(o.push({x:n,y:s.y}),o.push({x:n,y:a.y}))})),o}function cn(t,e,n){for(;e>t;e--){const t=n[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function un(t,e,n,i){return t&&e?i(t[n],e[n]):t?t[n]:e?e[n]:0}function hn(t,e){let n=[],r=!1;return(0,i.b)(t)?(r=!0,n=t):n=ln(t,e),n.length?new nn({points:n,options:{tension:0},_loop:r,_fullLoop:r}):null}function dn(t){return t&&!1!==t.fill}function fn(t,e,n){const r=t[e];let o=r.fill;const s=[e];let a;if(!n)return o;while(!1!==o&&-1===s.indexOf(o)){if(!(0,i.g)(o))return o;if(a=t[o],!a)return!1;if(a.visible)return o;s.push(o),o=a.fill}return!1}function pn(t,e,n){const r=xn(t);if((0,i.i)(r))return!isNaN(r.value)&&r;let o=parseFloat(r);return(0,i.g)(o)&&Math.floor(o)===o?gn(r[0],e,o,n):["origin","start","end","stack","shape"].indexOf(r)>=0&&r}function gn(t,e,n,i){return"-"!==t&&"+"!==t||(n=e+n),!(n===e||n<0||n>=i)&&n}function mn(t,e){let n=null;return"start"===t?n=e.bottom:"end"===t?n=e.top:(0,i.i)(t)?n=e.getPixelForValue(t.value):e.getBasePixel&&(n=e.getBasePixel()),n}function bn(t,e,n){let r;return r="start"===t?n:"end"===t?e.options.reverse?e.min:e.max:(0,i.i)(t)?t.value:e.getBaseValue(),r}function xn(t){const e=t.options,n=e.fill;let r=(0,i.v)(n&&n.target,n);return void 0===r&&(r=!!e.backgroundColor),!1!==r&&null!==r&&(!0===r?"origin":r)}function yn(t){const{scale:e,index:n,line:i}=t,r=[],o=i.segments,s=i.points,a=vn(e,n);a.push(hn({x:null,y:e.bottom},i));for(let l=0;l=0;--s){const e=r[s].$filler;e&&(e.line.updateControlPoints(o,e.axis),i&&e.fill&&An(t.ctx,e,o))}},beforeDatasetsDraw(t,e,n){if("beforeDatasetsDraw"!==n.drawTime)return;const i=t.getSortedVisibleDatasetMetas();for(let r=i.length-1;r>=0;--r){const e=i[r].$filler;dn(e)&&An(t.ctx,e,t.chartArea)}},beforeDatasetDraw(t,e,n){const i=e.meta.$filler;dn(i)&&"beforeDatasetDraw"===n.drawTime&&An(t.ctx,i,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const Nn=(t,e)=>{let{boxHeight:n=e,boxWidth:i=e}=t;return t.usePointStyle&&(n=Math.min(n,e),i=t.pointStyleWidth||Math.min(i,e)),{boxWidth:i,boxHeight:n,itemHeight:Math.max(e,n)}},Fn=(t,e)=>null!==t&&null!==e&&t.datasetIndex===e.datasetIndex&&t.index===e.index;class jn extends Rt{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,n){this.maxWidth=t,this.maxHeight=e,this._margins=n,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=(0,i.Q)(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,n)=>t.sort(e,n,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const n=t.labels,r=(0,i.a0)(n.font),o=r.size,s=this._computeTitleHeight(),{boxWidth:a,itemHeight:l}=Nn(n,o);let c,u;e.font=r.string,this.isHorizontal()?(c=this.maxWidth,u=this._fitRows(s,o,a,l)+10):(u=this.maxHeight,c=this._fitCols(s,r,a,l)+10),this.width=Math.min(c,t.maxWidth||this.maxWidth),this.height=Math.min(u,t.maxHeight||this.maxHeight)}_fitRows(t,e,n,i){const{ctx:r,maxWidth:o,options:{labels:{padding:s}}}=this,a=this.legendHitBoxes=[],l=this.lineWidths=[0],c=i+s;let u=t;r.textAlign="left",r.textBaseline="middle";let h=-1,d=-c;return this.legendItems.forEach(((t,f)=>{const p=n+e/2+r.measureText(t.text).width;(0===f||l[l.length-1]+p+2*s>o)&&(u+=c,l[l.length-(f>0?0:1)]=0,d+=c,h++),a[f]={left:0,top:d,row:h,width:p,height:i},l[l.length-1]+=p+s})),u}_fitCols(t,e,n,i){const{ctx:r,maxHeight:o,options:{labels:{padding:s}}}=this,a=this.legendHitBoxes=[],l=this.columnSizes=[],c=o-t;let u=s,h=0,d=0,f=0,p=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:g,itemHeight:m}=Hn(n,e,r,t,i);o>0&&d+m+2*s>c&&(u+=h+s,l.push({width:h,height:d}),f+=h+s,p++,h=d=0),a[o]={left:f,top:d,col:p,width:g,height:m},h=Math.max(h,g),d+=m+s})),u+=h,l.push({width:h,height:d}),u}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:n,labels:{padding:r},rtl:o}}=this,s=(0,i.aA)(o,this.left,this.width);if(this.isHorizontal()){let o=0,a=(0,i.a2)(n,this.left+r,this.right-this.lineWidths[o]);for(const l of e)o!==l.row&&(o=l.row,a=(0,i.a2)(n,this.left+r,this.right-this.lineWidths[o])),l.top+=this.top+t+r,l.left=s.leftForLtr(s.x(a),l.width),a+=l.width+r}else{let o=0,a=(0,i.a2)(n,this.top+t+r,this.bottom-this.columnSizes[o].height);for(const l of e)l.col!==o&&(o=l.col,a=(0,i.a2)(n,this.top+t+r,this.bottom-this.columnSizes[o].height)),l.top=a,l.left+=this.left+r,l.left=s.leftForLtr(s.x(l.left),l.width),a+=l.height+r}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;(0,i.Y)(t,this),this._draw(),(0,i.$)(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:n,ctx:r}=this,{align:o,labels:s}=t,a=i.d.color,l=(0,i.aA)(t.rtl,this.left,this.width),c=(0,i.a0)(s.font),{padding:u}=s,h=c.size,d=h/2;let f;this.drawTitle(),r.textAlign=l.textAlign("left"),r.textBaseline="middle",r.lineWidth=.5,r.font=c.string;const{boxWidth:p,boxHeight:g,itemHeight:m}=Nn(s,h),b=function(t,e,n){if(isNaN(p)||p<=0||isNaN(g)||g<0)return;r.save();const o=(0,i.v)(n.lineWidth,1);if(r.fillStyle=(0,i.v)(n.fillStyle,a),r.lineCap=(0,i.v)(n.lineCap,"butt"),r.lineDashOffset=(0,i.v)(n.lineDashOffset,0),r.lineJoin=(0,i.v)(n.lineJoin,"miter"),r.lineWidth=o,r.strokeStyle=(0,i.v)(n.strokeStyle,a),r.setLineDash((0,i.v)(n.lineDash,[])),s.usePointStyle){const a={radius:g*Math.SQRT2/2,pointStyle:n.pointStyle,rotation:n.rotation,borderWidth:o},c=l.xPlus(t,p/2),u=e+d;(0,i.aE)(r,a,c,u,s.pointStyleWidth&&p)}else{const s=e+Math.max((h-g)/2,0),a=l.leftForLtr(t,p),c=(0,i.ay)(n.borderRadius);r.beginPath(),Object.values(c).some((t=>0!==t))?(0,i.aw)(r,{x:a,y:s,w:p,h:g,radius:c}):r.rect(a,s,p,g),r.fill(),0!==o&&r.stroke()}r.restore()},x=function(t,e,n){(0,i.Z)(r,n.text,t,e+m/2,c,{strikethrough:n.hidden,textAlign:l.textAlign(n.textAlign)})},y=this.isHorizontal(),v=this._computeTitleHeight();f=y?{x:(0,i.a2)(o,this.left+u,this.right-n[0]),y:this.top+u+v,line:0}:{x:this.left+u,y:(0,i.a2)(o,this.top+v+u,this.bottom-e[0].height),line:0},(0,i.aB)(this.ctx,t.textDirection);const w=m+u;this.legendItems.forEach(((a,h)=>{r.strokeStyle=a.fontColor,r.fillStyle=a.fontColor;const g=r.measureText(a.text).width,m=l.textAlign(a.textAlign||(a.textAlign=s.textAlign)),k=p+d+g;let _=f.x,M=f.y;l.setWidth(this.width),y?h>0&&_+k+u>this.right&&(M=f.y+=w,f.line++,_=f.x=(0,i.a2)(o,this.left+u,this.right-n[f.line])):h>0&&M+w>this.bottom&&(_=f.x=_+e[f.line].width+u,f.line++,M=f.y=(0,i.a2)(o,this.top+v+u,this.bottom-e[f.line].height));const S=l.x(_);if(b(S,M,a),_=(0,i.aC)(m,_+p+d,y?_+k:this.right,t.rtl),x(l.x(_),M,a),y)f.x+=k+u;else if("string"!==typeof a.text){const t=c.lineHeight;f.y+=Bn(a,t)+u}else f.y+=w})),(0,i.aD)(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,n=(0,i.a0)(e.font),r=(0,i.E)(e.padding);if(!e.display)return;const o=(0,i.aA)(t.rtl,this.left,this.width),s=this.ctx,a=e.position,l=n.size/2,c=r.top+l;let u,h=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),u=this.top+c,h=(0,i.a2)(t.align,h,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);u=c+(0,i.a2)(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const f=(0,i.a2)(a,h,h+d);s.textAlign=o.textAlign((0,i.a1)(a)),s.textBaseline="middle",s.strokeStyle=e.color,s.fillStyle=e.color,s.font=n.string,(0,i.Z)(s,e.text,f,u,n)}_computeTitleHeight(){const t=this.options.title,e=(0,i.a0)(t.font),n=(0,i.E)(t.padding);return t.display?e.lineHeight+n.height:0}_getLegendItemAt(t,e){let n,r,o;if((0,i.ak)(t,this.left,this.right)&&(0,i.ak)(e,this.top,this.bottom))for(o=this.legendHitBoxes,n=0;nt.length>e.length?t:e))),e+n.size/2+i.measureText(r).width}function $n(t,e,n){let i=t;return"string"!==typeof e.text&&(i=Bn(e,n)),i}function Bn(t,e){const n=t.text?t.text.length:0;return e*n}function Yn(t,e){return!("mousemove"!==t&&"mouseout"!==t||!e.onHover&&!e.onLeave)||!(!e.onClick||"click"!==t&&"mouseup"!==t)}var Vn={id:"legend",_element:jn,start(t,e,n){const i=t.legend=new jn({ctx:t.ctx,options:n,chart:t});ct.configure(t,i,n),ct.addBox(t,i)},stop(t){ct.removeBox(t,t.legend),delete t.legend},beforeUpdate(t,e,n){const i=t.legend;ct.configure(t,i,n),i.options=n},afterUpdate(t){const e=t.legend;e.buildLabels(),e.adjustHitBoxes()},afterEvent(t,e){e.replay||t.legend.handleEvent(e.event)},defaults:{display:!0,position:"top",align:"center",fullSize:!0,reverse:!1,weight:1e3,onClick(t,e,n){const i=e.datasetIndex,r=n.chart;r.isDatasetVisible(i)?(r.hide(i),e.hidden=!0):(r.show(i),e.hidden=!1)},onHover:null,onLeave:null,labels:{color:t=>t.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:n,pointStyle:r,textAlign:o,color:s,useBorderRadius:a,borderRadius:l}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const c=t.controller.getStyle(n?0:void 0),u=(0,i.E)(c.borderWidth);return{text:e[t.index].label,fillStyle:c.backgroundColor,fontColor:s,hidden:!t.visible,lineCap:c.borderCapStyle,lineDash:c.borderDash,lineDashOffset:c.borderDashOffset,lineJoin:c.borderJoinStyle,lineWidth:(u.width+u.height)/4,strokeStyle:c.borderColor,pointStyle:r||c.pointStyle,rotation:c.rotation,textAlign:o||c.textAlign,borderRadius:a&&(l||c.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class Un extends Rt{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const n=this.options;if(this.left=0,this.top=0,!n.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const r=(0,i.b)(n.text)?n.text.length:1;this._padding=(0,i.E)(n.padding);const o=r*(0,i.a0)(n.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:n,bottom:r,right:o,options:s}=this,a=s.align;let l,c,u,h=0;return this.isHorizontal()?(c=(0,i.a2)(a,n,o),u=e+t,l=o-n):("left"===s.position?(c=n+t,u=(0,i.a2)(a,r,e),h=-.5*i.P):(c=o-t,u=(0,i.a2)(a,e,r),h=.5*i.P),l=r-e),{titleX:c,titleY:u,maxWidth:l,rotation:h}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const n=(0,i.a0)(e.font),r=n.lineHeight,o=r/2+this._padding.top,{titleX:s,titleY:a,maxWidth:l,rotation:c}=this._drawArgs(o);(0,i.Z)(t,e.text,0,0,n,{color:e.color,maxWidth:l,rotation:c,textAlign:(0,i.a1)(e.align),textBaseline:"middle",translation:[s,a]})}}function qn(t,e){const n=new Un({ctx:t.ctx,options:e,chart:t});ct.configure(t,n,e),ct.addBox(t,n),t.titleBlock=n}var Xn={id:"title",_element:Un,start(t,e,n){qn(t,n)},stop(t){const e=t.titleBlock;ct.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,n){const i=t.titleBlock;ct.configure(t,i,n),i.options=n},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};new WeakMap;const Gn={average(t){if(!t.length)return!1;let e,n,i=new Set,r=0,o=0;for(e=0,n=t.length;et+e))/i.size;return{x:s,y:r/o}},nearest(t,e){if(!t.length)return!1;let n,r,o,s=e.x,a=e.y,l=Number.POSITIVE_INFINITY;for(n=0,r=t.length;n-1?t.split("\n"):t}function Jn(t,e){const{element:n,datasetIndex:i,index:r}=e,o=t.getDatasetMeta(i).controller,{label:s,value:a}=o.getLabelAndValue(r);return{chart:t,label:s,parsed:o.getParsed(r),raw:t.data.datasets[i].data[r],formattedValue:a,dataset:o.getDataset(),dataIndex:r,datasetIndex:i,element:n}}function Kn(t,e){const n=t.chart.ctx,{body:r,footer:o,title:s}=t,{boxWidth:a,boxHeight:l}=e,c=(0,i.a0)(e.bodyFont),u=(0,i.a0)(e.titleFont),h=(0,i.a0)(e.footerFont),d=s.length,f=o.length,p=r.length,g=(0,i.E)(e.padding);let m=g.height,b=0,x=r.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*u.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){const t=e.displayColors?Math.max(l,c.lineHeight):c.lineHeight;m+=p*t+(x-p)*c.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*h.lineHeight+(f-1)*e.footerSpacing);let y=0;const v=function(t){b=Math.max(b,n.measureText(t).width+y)};return n.save(),n.font=u.string,(0,i.F)(t.title,v),n.font=c.string,(0,i.F)(t.beforeBody.concat(t.afterBody),v),y=e.displayColors?a+2+e.boxPadding:0,(0,i.F)(r,(t=>{(0,i.F)(t.before,v),(0,i.F)(t.lines,v),(0,i.F)(t.after,v)})),y=0,n.font=h.string,(0,i.F)(t.footer,v),n.restore(),b+=g.width,{width:b,height:m}}function ti(t,e){const{y:n,height:i}=e;return nt.height-i/2?"bottom":"center"}function ei(t,e,n,i){const{x:r,width:o}=i,s=n.caretSize+n.caretPadding;return"left"===t&&r+o+s>e.width||("right"===t&&r-o-s<0||void 0)}function ni(t,e,n,i){const{x:r,width:o}=n,{width:s,chartArea:{left:a,right:l}}=t;let c="center";return"center"===i?c=r<=(a+l)/2?"left":"right":r<=o/2?c="left":r>=s-o/2&&(c="right"),ei(c,t,e,n)&&(c="center"),c}function ii(t,e,n){const i=n.yAlign||e.yAlign||ti(t,n);return{xAlign:n.xAlign||e.xAlign||ni(t,e,n,i),yAlign:i}}function ri(t,e){let{x:n,width:i}=t;return"right"===e?n-=i:"center"===e&&(n-=i/2),n}function oi(t,e,n){let{y:i,height:r}=t;return"top"===e?i+=n:i-="bottom"===e?r+n:r/2,i}function si(t,e,n,r){const{caretSize:o,caretPadding:s,cornerRadius:a}=t,{xAlign:l,yAlign:c}=n,u=o+s,{topLeft:h,topRight:d,bottomLeft:f,bottomRight:p}=(0,i.ay)(a);let g=ri(e,l);const m=oi(e,c,u);return"center"===c?"left"===l?g+=u:"right"===l&&(g-=u):"left"===l?g-=Math.max(h,f)+o:"right"===l&&(g+=Math.max(d,p)+o),{x:(0,i.S)(g,0,r.width-e.width),y:(0,i.S)(m,0,r.height-e.height)}}function ai(t,e,n){const r=(0,i.E)(n.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-r.right:t.x+r.left}function li(t){return Zn([],Qn(t))}function ci(t,e,n){return(0,i.j)(t,{tooltip:e,tooltipItems:n,type:"tooltip"})}function ui(t,e){const n=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return n?t.override(n):t}const hi={beforeTitle:i.aG,title(t){if(t.length>0){const e=t[0],n=e.chart.data.labels,i=n?n.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(i>0&&e.dataIndex{const e={before:[],lines:[],after:[]},i=ui(n,t);Zn(e.before,Qn(di(i,"beforeLabel",this,t))),Zn(e.lines,di(i,"label",this,t)),Zn(e.after,Qn(di(i,"afterLabel",this,t))),r.push(e)})),r}getAfterBody(t,e){return li(di(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:n}=e,i=di(n,"beforeFooter",this,t),r=di(n,"footer",this,t),o=di(n,"afterFooter",this,t);let s=[];return s=Zn(s,Qn(i)),s=Zn(s,Qn(r)),s=Zn(s,Qn(o)),s}_createItems(t){const e=this._active,n=this.chart.data,r=[],o=[],s=[];let a,l,c=[];for(a=0,l=e.length;at.filter(e,i,r,n)))),t.itemSort&&(c=c.sort(((e,i)=>t.itemSort(e,i,n)))),(0,i.F)(c,(e=>{const n=ui(t.callbacks,e);r.push(di(n,"labelColor",this,e)),o.push(di(n,"labelPointStyle",this,e)),s.push(di(n,"labelTextColor",this,e))})),this.labelColors=r,this.labelPointStyles=o,this.labelTextColors=s,this.dataPoints=c,c}update(t,e){const n=this.options.setContext(this.getContext()),i=this._active;let r,o=[];if(i.length){const t=Gn[n.position].call(this,i,this._eventPosition);o=this._createItems(n),this.title=this.getTitle(o,n),this.beforeBody=this.getBeforeBody(o,n),this.body=this.getBody(o,n),this.afterBody=this.getAfterBody(o,n),this.footer=this.getFooter(o,n);const e=this._size=Kn(this,n),s=Object.assign({},t,e),a=ii(this.chart,n,s),l=si(n,s,a,this.chart);this.xAlign=a.xAlign,this.yAlign=a.yAlign,r={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(r={opacity:0});this._tooltipItems=o,this.$context=void 0,r&&this._resolveAnimations().update(this,r),t&&n.external&&n.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,n,i){const r=this.getCaretPosition(t,n,i);e.lineTo(r.x1,r.y1),e.lineTo(r.x2,r.y2),e.lineTo(r.x3,r.y3)}getCaretPosition(t,e,n){const{xAlign:r,yAlign:o}=this,{caretSize:s,cornerRadius:a}=n,{topLeft:l,topRight:c,bottomLeft:u,bottomRight:h}=(0,i.ay)(a),{x:d,y:f}=t,{width:p,height:g}=e;let m,b,x,y,v,w;return"center"===o?(v=f+g/2,"left"===r?(m=d,b=m-s,y=v+s,w=v-s):(m=d+p,b=m+s,y=v-s,w=v+s),x=m):(b="left"===r?d+Math.max(l,u)+s:"right"===r?d+p-Math.max(c,h)-s:this.caretX,"top"===o?(y=f,v=y-s,m=b-s,x=b+s):(y=f+g,v=y+s,m=b+s,x=b-s),w=y),{x1:m,x2:b,x3:x,y1:y,y2:v,y3:w}}drawTitle(t,e,n){const r=this.title,o=r.length;let s,a,l;if(o){const c=(0,i.aA)(n.rtl,this.x,this.width);for(t.x=ai(this,n.titleAlign,n),e.textAlign=c.textAlign(n.titleAlign),e.textBaseline="middle",s=(0,i.a0)(n.titleFont),a=n.titleSpacing,e.fillStyle=n.titleColor,e.font=s.string,l=0;l0!==t))?(t.beginPath(),t.fillStyle=o.multiKeyBackground,(0,i.aw)(t,{x:e,y:p,w:c,h:l,radius:a}),t.fill(),t.stroke(),t.fillStyle=s.backgroundColor,t.beginPath(),(0,i.aw)(t,{x:n,y:p+1,w:c-2,h:l-2,radius:a}),t.fill()):(t.fillStyle=o.multiKeyBackground,t.fillRect(e,p,c,l),t.strokeRect(e,p,c,l),t.fillStyle=s.backgroundColor,t.fillRect(n,p+1,c-2,l-2))}t.fillStyle=this.labelTextColors[n]}drawBody(t,e,n){const{body:r}=this,{bodySpacing:o,bodyAlign:s,displayColors:a,boxHeight:l,boxWidth:c,boxPadding:u}=n,h=(0,i.a0)(n.bodyFont);let d=h.lineHeight,f=0;const p=(0,i.aA)(n.rtl,this.x,this.width),g=function(n){e.fillText(n,p.x(t.x+f),t.y+d/2),t.y+=d+o},m=p.textAlign(s);let b,x,y,v,w,k,_;for(e.textAlign=s,e.textBaseline="middle",e.font=h.string,t.x=ai(this,m,n),e.fillStyle=n.bodyColor,(0,i.F)(this.beforeBody,g),f=a&&"right"!==m?"center"===s?c/2+u:c+2+u:0,v=0,k=r.length;v0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,n=this.$animations,i=n&&n.x,r=n&&n.y;if(i||r){const n=Gn[t.position].call(this,this._active,this._eventPosition);if(!n)return;const o=this._size=Kn(this,t),s=Object.assign({},n,this._size),a=ii(e,t,s),l=si(t,s,a,e);i._to===l.x&&r._to===l.y||(this.xAlign=a.xAlign,this.yAlign=a.yAlign,this.width=o.width,this.height=o.height,this.caretX=n.x,this.caretY=n.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let n=this.opacity;if(!n)return;this._updateAnimationTarget(e);const r={width:this.width,height:this.height},o={x:this.x,y:this.y};n=Math.abs(n)<.001?0:n;const s=(0,i.E)(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=n,this.drawBackground(o,t,r,e),(0,i.aB)(t,e.textDirection),o.y+=s.top,this.drawTitle(o,t,e),this.drawBody(o,t,e),this.drawFooter(o,t,e),(0,i.aD)(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const n=this._active,r=t.map((({datasetIndex:t,index:e})=>{const n=this.chart.getDatasetMeta(t);if(!n)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:n.data[e],index:e}})),o=!(0,i.ai)(n,r),s=this._positionChanged(r,e);(o||s)&&(this._active=r,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,n=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const r=this.options,o=this._active||[],s=this._getActiveElements(t,o,e,n),a=this._positionChanged(s,t),l=e||!(0,i.ai)(s,o)||a;return l&&(this._active=s,(r.enabled||r.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),l}_getActiveElements(t,e,n,i){const r=this.options;if("mouseout"===t.type)return[];if(!i)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,r.mode,r,n);return r.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:n,caretY:i,options:r}=this,o=Gn[r.position].call(this,t,e);return!1!==o&&(n!==o.x||i!==o.y)}}var pi={id:"tooltip",_element:fi,positioners:Gn,afterInit(t,e,n){n&&(t.tooltip=new fi({chart:t,options:n}))},beforeUpdate(t,e,n){t.tooltip&&t.tooltip.initialize(n)},reset(t,e,n){t.tooltip&&t.tooltip.initialize(n)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const n={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...n,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",n)}},afterEvent(t,e){if(t.tooltip){const n=e.replay;t.tooltip.handleEvent(e.event,n,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:hi},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};const gi=(t,e,n,i)=>("string"===typeof e?(n=t.push(e)-1,i.unshift({index:n,label:e})):isNaN(e)&&(n=null),n);function mi(t,e,n,i){const r=t.indexOf(e);if(-1===r)return gi(t,e,n,i);const o=t.lastIndexOf(e);return r!==o?n:r}const bi=(t,e)=>null===t?null:(0,i.S)(Math.round(t),0,e);function xi(t){const e=this.getLabels();return t>=0&&te.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}}function vi(t,e){const n=[],r=1e-14,{bounds:o,step:s,min:a,max:l,precision:c,count:u,maxTicks:h,maxDigits:d,includeBounds:f}=t,p=s||1,g=h-1,{min:m,max:b}=e,x=!(0,i.k)(a),y=!(0,i.k)(l),v=!(0,i.k)(u),w=(b-m)/(d+1);let k,_,M,S,T=(0,i.aI)((b-m)/g/p)*p;if(Tg&&(T=(0,i.aI)(S*T/g/p)*p),(0,i.k)(c)||(k=Math.pow(10,c),T=Math.ceil(T*k)/k),"ticks"===o?(_=Math.floor(m/T)*T,M=Math.ceil(b/T)*T):(_=m,M=b),x&&y&&s&&(0,i.aJ)((l-a)/s,T/1e3)?(S=Math.round(Math.min((l-a)/T,h)),T=(l-a)/S,_=a,M=l):v?(_=x?a:_,M=y?l:M,S=u-1,T=(M-_)/S):(S=(M-_)/T,S=(0,i.aK)(S,Math.round(S),T/1e3)?Math.round(S):Math.ceil(S));const D=Math.max((0,i.aL)(T),(0,i.aL)(_));k=Math.pow(10,(0,i.k)(c)?D:c),_=Math.round(_*k)/k,M=Math.round(M*k)/k;let C=0;for(x&&(f&&_!==a?(n.push({value:a}),_l)break;n.push({value:t})}return y&&f&&M!==l?n.length&&(0,i.aK)(n[n.length-1].value,l,wi(l,w,t))?n[n.length-1].value=l:n.push({value:l}):y&&M!==l||n.push({value:M}),n}function wi(t,e,{horizontal:n,minRotation:r}){const o=(0,i.t)(r),s=(n?Math.sin(o):Math.cos(o))||.001,a=.75*e*(""+t).length;return Math.min(e/s,a)}class ki extends Kt{constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(t,e){return(0,i.k)(t)||("number"===typeof t||t instanceof Number)&&!isFinite(+t)?null:+t}handleTickRangeOptions(){const{beginAtZero:t}=this.options,{minDefined:e,maxDefined:n}=this.getUserBounds();let{min:r,max:o}=this;const s=t=>r=e?r:t,a=t=>o=n?o:t;if(t){const t=(0,i.s)(r),e=(0,i.s)(o);t<0&&e<0?a(0):t>0&&e>0&&s(0)}if(r===o){let e=0===o?1:Math.abs(.05*o);a(o+e),t||s(r-e)}this.min=r,this.max=o}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:n,stepSize:i}=t;return i?(e=Math.ceil(this.max/i)-Math.floor(this.min/i)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${i} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),n=n||11),n&&(e=Math.min(n,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let n=this.getTickLimit();n=Math.max(2,n);const r={maxTicks:n,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},o=this._range||this,s=vi(r,o);return"ticks"===t.bounds&&(0,i.aH)(s,this,"value"),t.reverse?(s.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),s}configure(){const t=this.ticks;let e=this.min,n=this.max;if(super.configure(),this.options.offset&&t.length){const i=(n-e)/Math.max(t.length-1,1)/2;e-=i,n+=i}this._startValue=e,this._endValue=n,this._valueRange=n-e}getLabelForValue(t){return(0,i.o)(t,this.chart.options.locale,this.options.ticks.format)}}class _i extends ki{static id="linear";static defaults={ticks:{callback:i.aM.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=(0,i.g)(t)?t:0,this.max=(0,i.g)(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,n=(0,i.t)(this.options.ticks.minRotation),r=(t?Math.sin(n):Math.cos(n))||.001,o=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,o.lineHeight/r))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}i.aM.formatters.logarithmic;i.aM.formatters.numeric;const Mi={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Si=Object.keys(Mi);function Ti(t,e){return t-e}function Di(t,e){if((0,i.k)(e))return null;const n=t._adapter,{parser:r,round:o,isoWeekday:s}=t._parseOpts;let a=e;return"function"===typeof r&&(a=r(a)),(0,i.g)(a)||(a="string"===typeof r?n.parse(a,r):n.parse(a)),null===a?null:(o&&(a="week"!==o||!(0,i.x)(s)&&!0!==s?n.startOf(a,o):n.startOf(a,"isoWeek",s)),+a)}function Ci(t,e,n,i){const r=Si.length;for(let o=Si.indexOf(t);o=Si.indexOf(n);o--){const n=Si[o];if(Mi[n].common&&t._adapter.diff(r,i,n)>=e-1)return n}return Si[n?Si.indexOf(n):0]}function Oi(t){for(let e=Si.indexOf(t)+1,n=Si.length;e=e?n[r]:n[o];t[s]=!0}}else t[e]=!0}function Ei(t,e,n,i){const r=t._adapter,o=+r.startOf(e[0].value,i),s=e[e.length-1].value;let a,l;for(a=o;a<=s;a=+r.add(a,1,i))l=n[a],l>=0&&(e[l].major=!0);return e}function Ri(t,e,n){const i=[],r={},o=e.length;let s,a;for(s=0;s+t.value)))}initOffsets(t=[]){let e,n,r=0,o=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),r=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,n=this.getDecimalForValue(t[t.length-1]),o=1===t.length?n:(n-this.getDecimalForValue(t[t.length-2]))/2);const s=t.length<3?.5:.25;r=(0,i.S)(r,0,s),o=(0,i.S)(o,0,s),this._offsets={start:r,end:o,factor:1/(r+1+o)}}_generate(){const t=this._adapter,e=this.min,n=this.max,r=this.options,o=r.time,s=o.unit||Ci(o.minUnit,e,n,this._getLabelCapacity(e)),a=(0,i.v)(r.ticks.stepSize,1),l="week"===s&&o.isoWeekday,c=(0,i.x)(l)||!0===l,u={};let h,d,f=e;if(c&&(f=+t.startOf(f,"isoWeek",l)),f=+t.startOf(f,c?"day":s),t.diff(n,e,s)>1e5*a)throw new Error(e+" and "+n+" are too far apart with stepSize of "+a+" "+s);const p="data"===r.ticks.source&&this.getDataTimestamps();for(h=f,d=0;h+t))}getLabelForValue(t){const e=this._adapter,n=this.options.time;return n.tooltipFormat?e.format(t,n.tooltipFormat):e.format(t,n.displayFormats.datetime)}format(t,e){const n=this.options,i=n.time.displayFormats,r=this._unit,o=e||i[r];return this._adapter.format(t,o)}_tickFormatFunction(t,e,n,r){const o=this.options,s=o.ticks.callback;if(s)return(0,i.Q)(s,[t,e,n],this);const a=o.time.displayFormats,l=this._unit,c=this._majorUnit,u=l&&a[l],h=c&&a[c],d=n[e],f=c&&h&&d&&d.major;return this._adapter.format(t,r||(f?h:u))}generateTickLabels(t){let e,n,i;for(e=0,n=t.length;e0?s:1}getDataTimestamps(){let t,e,n=this._cache.data||[];if(n.length)return n;const i=this.getMatchingVisibleMetas();if(this._normalized&&i.length)return this._cache.data=i[0].controller.getAllParsedValues(this);for(t=0,e=i.length;tMath.max(Math.min(t,n),e);function o(t){return r(i(2.55*t),0,255)}function s(t){return r(i(255*t),0,255)}function a(t){return r(i(t/2.55)/100,0,1)}function l(t){return r(i(100*t),0,100)}const c={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},u=[..."0123456789ABCDEF"],h=t=>u[15&t],d=t=>u[(240&t)>>4]+u[15&t],f=t=>(240&t)>>4===(15&t),p=t=>f(t.r)&&f(t.g)&&f(t.b)&&f(t.a);function g(t){var e,n=t.length;return"#"===t[0]&&(4===n||5===n?e={r:255&17*c[t[1]],g:255&17*c[t[2]],b:255&17*c[t[3]],a:5===n?17*c[t[4]]:255}:7!==n&&9!==n||(e={r:c[t[1]]<<4|c[t[2]],g:c[t[3]]<<4|c[t[4]],b:c[t[5]]<<4|c[t[6]],a:9===n?c[t[7]]<<4|c[t[8]]:255})),e}const m=(t,e)=>t<255?e(t):"";function b(t){var e=p(t)?h:d;return t?"#"+e(t.r)+e(t.g)+e(t.b)+m(t.a,e):void 0}const x=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function y(t,e,n){const i=e*Math.min(n,1-n),r=(e,r=(e+t/30)%12)=>n-i*Math.max(Math.min(r-3,9-r,1),-1);return[r(0),r(8),r(4)]}function v(t,e,n){const i=(i,r=(i+t/60)%6)=>n-n*e*Math.max(Math.min(r,4-r,1),0);return[i(5),i(3),i(1)]}function w(t,e,n){const i=y(t,1,.5);let r;for(e+n>1&&(r=1/(e+n),e*=r,n*=r),r=0;r<3;r++)i[r]*=1-e-n,i[r]+=e;return i}function k(t,e,n,i,r){return t===r?(e-n)/i+(e.5?u/(2-o-s):u/(o+s),l=k(n,i,r,u,o),l=60*l+.5),[0|l,c||0,a]}function M(t,e,n,i){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,n,i)).map(s)}function S(t,e,n){return M(y,t,e,n)}function T(t,e,n){return M(w,t,e,n)}function D(t,e,n){return M(v,t,e,n)}function C(t){return(t%360+360)%360}function A(t){const e=x.exec(t);let n,i=255;if(!e)return;e[5]!==n&&(i=e[6]?o(+e[5]):s(+e[5]));const r=C(+e[2]),a=+e[3]/100,l=+e[4]/100;return n="hwb"===e[1]?T(r,a,l):"hsv"===e[1]?D(r,a,l):S(r,a,l),{r:n[0],g:n[1],b:n[2],a:i}}function O(t,e){var n=_(t);n[0]=C(n[0]+e),n=S(n),t.r=n[0],t.g=n[1],t.b=n[2]}function P(t){if(!t)return;const e=_(t),n=e[0],i=l(e[1]),r=l(e[2]);return t.a<255?`hsla(${n}, ${i}%, ${r}%, ${a(t.a)})`:`hsl(${n}, ${i}%, ${r}%)`}const E={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},R={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};function I(){const t={},e=Object.keys(R),n=Object.keys(E);let i,r,o,s,a;for(i=0;i>16&255,o>>8&255,255&o]}return t}let L;function z(t){L||(L=I(),L.transparent=[0,0,0,0]);const e=L[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const N=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;function F(t){const e=N.exec(t);let n,i,s,a=255;if(e){if(e[7]!==n){const t=+e[7];a=e[8]?o(t):r(255*t,0,255)}return n=+e[1],i=+e[3],s=+e[5],n=255&(e[2]?o(n):r(n,0,255)),i=255&(e[4]?o(i):r(i,0,255)),s=255&(e[6]?o(s):r(s,0,255)),{r:n,g:i,b:s,a:a}}}function j(t){return t&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${a(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`)}const H=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,W=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function $(t,e,n){const i=W(a(t.r)),r=W(a(t.g)),o=W(a(t.b));return{r:s(H(i+n*(W(a(e.r))-i))),g:s(H(r+n*(W(a(e.g))-r))),b:s(H(o+n*(W(a(e.b))-o))),a:t.a+n*(e.a-t.a)}}function B(t,e,n){if(t){let i=_(t);i[e]=Math.max(0,Math.min(i[e]+i[e]*n,0===e?360:1)),i=S(i),t.r=i[0],t.g=i[1],t.b=i[2]}}function Y(t,e){return t?Object.assign(e||{},t):t}function V(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=s(t[3]))):(e=Y(t,{r:0,g:0,b:0,a:1}),e.a=s(e.a)),e}function U(t){return"r"===t.charAt(0)?F(t):A(t)}class q{constructor(t){if(t instanceof q)return t;const e=typeof t;let n;"object"===e?n=V(t):"string"===e&&(n=g(t)||z(t)||U(t)),this._rgb=n,this._valid=!!n}get valid(){return this._valid}get rgb(){var t=Y(this._rgb);return t&&(t.a=a(t.a)),t}set rgb(t){this._rgb=V(t)}rgbString(){return this._valid?j(this._rgb):void 0}hexString(){return this._valid?b(this._rgb):void 0}hslString(){return this._valid?P(this._rgb):void 0}mix(t,e){if(t){const n=this.rgb,i=t.rgb;let r;const o=e===r?.5:e,s=2*o-1,a=n.a-i.a,l=((s*a===-1?s:(s+a)/(1+s*a))+1)/2;r=1-l,n.r=255&l*n.r+r*i.r+.5,n.g=255&l*n.g+r*i.g+.5,n.b=255&l*n.b+r*i.b+.5,n.a=o*n.a+(1-o)*i.a,this.rgb=n}return this}interpolate(t,e){return t&&(this._rgb=$(this._rgb,t._rgb,e)),this}clone(){return new q(this.rgb)}alpha(t){return this._rgb.a=s(t),this}clearer(t){const e=this._rgb;return e.a*=1-t,this}greyscale(){const t=this._rgb,e=i(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){const e=this._rgb;return e.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return B(this._rgb,2,t),this}darken(t){return B(this._rgb,2,-t),this}saturate(t){return B(this._rgb,1,t),this}desaturate(t){return B(this._rgb,1,-t),this}rotate(t){return O(this._rgb,t),this}}
/*!
* Chart.js v4.5.1
* https://www.chartjs.org
* (c) 2025 Chart.js Contributors
* Released under the MIT License
*/
function X(){}const G=(()=>{let t=0;return()=>t++})();function Z(t){return null===t||void 0===t}function Q(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function J(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function K(t){return("number"===typeof t||t instanceof Number)&&isFinite(+t)}function tt(t,e){return K(t)?t:e}function et(t,e){return"undefined"===typeof t?e:t}const nt=(t,e)=>"string"===typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,it=(t,e)=>"string"===typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function rt(t,e,n){if(t&&"function"===typeof t.call)return t.apply(n,e)}function ot(t,e,n,i){let r,o,s;if(Q(t))if(o=t.length,i)for(r=o-1;r>=0;r--)e.call(n,t[r],r);else for(r=0;rt,x:t=>t.x,y:t=>t.y};function pt(t){const e=t.split("."),n=[];let i="";for(const r of e)i+=r,i.endsWith("\\")?i=i.slice(0,-1)+".":(n.push(i),i="");return n}function gt(t){const e=pt(t);return t=>{for(const n of e){if(""===n)break;t=t&&t[n]}return t}}function mt(t,e){const n=ft[e]||(ft[e]=gt(e));return n(t)}function bt(t){return t.charAt(0).toUpperCase()+t.slice(1)}const xt=t=>"undefined"!==typeof t,yt=t=>"function"===typeof t,vt=(t,e)=>{if(t.size!==e.size)return!1;for(const n of t)if(!e.has(n))return!1;return!0};function wt(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const kt=Math.PI,_t=2*kt,Mt=_t+kt,St=Number.POSITIVE_INFINITY,Tt=kt/180,Dt=kt/2,Ct=kt/4,At=2*kt/3,Ot=Math.log10,Pt=Math.sign;function Et(t,e,n){return Math.abs(t-e)t-e)).pop(),e}function Lt(t){return"symbol"===typeof t||"object"===typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}function zt(t){return!Lt(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function Nt(t,e){const n=Math.round(t);return n-e<=t&&n+e>=t}function Ft(t,e,n){let i,r,o;for(i=0,r=t.length;il&&c=Math.min(e,n)-i&&t<=Math.max(e,n)+i}function Zt(t,e,n){n=n||(n=>t[n]1)i=o+r>>1,n(i)?o=i:r=i;return{lo:o,hi:r}}const Qt=(t,e,n,i)=>Zt(t,n,i?i=>{const r=t[i][e];return rt[i][e]Zt(t,n,(i=>t[i][e]>=n));function Kt(t,e,n){let i=0,r=t.length;while(ii&&t[r-1]>n)r--;return i>0||r{const n="_onData"+bt(e),i=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const r=i.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"===typeof t[n]&&t[n](...e)})),r}})})))}function ne(t,e){const n=t._chartjs;if(!n)return;const i=n.listeners,r=i.indexOf(e);-1!==r&&i.splice(r,1),i.length>0||(te.forEach((e=>{delete t[e]})),delete t._chartjs)}function ie(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const re=function(){return"undefined"===typeof window?function(t){return t()}:window.requestAnimationFrame}();function oe(t,e){let n=[],i=!1;return function(...r){n=r,i||(i=!0,re.call(window,(()=>{i=!1,t.apply(e,n)})))}}function se(t,e){let n;return function(...i){return e?(clearTimeout(n),n=setTimeout(t,e,i)):t.apply(this,i),e}}const ae=t=>"start"===t?"left":"end"===t?"right":"center",le=(t,e,n)=>"start"===t?e:"end"===t?n:(e+n)/2,ce=(t,e,n,i)=>{const r=i?"left":"right";return t===r?n:"center"===t?(e+n)/2:e};function ue(t,e,n){const i=e.length;let r=0,o=i;if(t._sorted){const{iScale:s,vScale:a,_parsed:l}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,u=s.axis,{min:h,max:d,minDefined:f,maxDefined:p}=s.getUserBounds();if(f){if(r=Math.min(Qt(l,u,h).lo,n?i:Qt(e,u,s.getPixelForValue(h)).lo),c){const t=l.slice(0,r+1).reverse().findIndex((t=>!Z(t[a.axis])));r-=Math.max(0,t)}r=qt(r,0,i-1)}if(p){let t=Math.max(Qt(l,s.axis,d,!0).hi+1,n?0:Qt(e,u,s.getPixelForValue(d),!0).hi+1);if(c){const e=l.slice(t-1).findIndex((t=>!Z(t[a.axis])));t+=Math.max(0,e)}o=qt(t,r,i)-r}else o=i-r}return{start:r,count:o}}function he(t){const{xScale:e,yScale:n,_scaleRanges:i}=t,r={xmin:e.min,xmax:e.max,ymin:n.min,ymax:n.max};if(!i)return t._scaleRanges=r,!0;const o=i.xmin!==e.min||i.xmax!==e.max||i.ymin!==n.min||i.ymax!==n.max;return Object.assign(i,r),o}const de=t=>0===t||1===t,fe=(t,e,n)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*_t/n),pe=(t,e,n)=>Math.pow(2,-10*t)*Math.sin((t-e)*_t/n)+1,ge={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*Dt),easeOutSine:t=>Math.sin(t*Dt),easeInOutSine:t=>-.5*(Math.cos(kt*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>de(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>de(t)?t:fe(t,.075,.3),easeOutElastic:t=>de(t)?t:pe(t,.075,.3),easeInOutElastic(t){const e=.1125,n=.45;return de(t)?t:t<.5?.5*fe(2*t,e,n):.5+.5*pe(2*t-1,e,n)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-ge.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,n=2.75;return t<1/n?e*t*t:t<2/n?e*(t-=1.5/n)*t+.75:t<2.5/n?e*(t-=2.25/n)*t+.9375:e*(t-=2.625/n)*t+.984375},easeInOutBounce:t=>t<.5?.5*ge.easeInBounce(2*t):.5*ge.easeOutBounce(2*t-1)+.5};function me(t){if(t&&"object"===typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function be(t){return me(t)?t:new q(t)}function xe(t){return me(t)?t:new q(t).saturate(.5).darken(.1).hexString()}const ye=["x","y","borderWidth","radius","tension"],ve=["color","borderColor","backgroundColor"];function we(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ve},numbers:{type:"number",properties:ye}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})}function ke(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})}const _e=new Map;function Me(t,e){e=e||{};const n=t+JSON.stringify(e);let i=_e.get(n);return i||(i=new Intl.NumberFormat(t,e),_e.set(n,i)),i}function Se(t,e,n){return Me(e,n).format(t)}const Te={values(t){return Q(t)?t:""+t},numeric(t,e,n){if(0===t)return"0";const i=this.chart.options.locale;let r,o=t;if(n.length>1){const e=Math.max(Math.abs(n[0].value),Math.abs(n[n.length-1].value));(e<1e-4||e>1e15)&&(r="scientific"),o=De(t,n)}const s=Ot(Math.abs(o)),a=isNaN(s)?1:Math.max(Math.min(-1*Math.floor(s),20),0),l={notation:r,minimumFractionDigits:a,maximumFractionDigits:a};return Object.assign(l,this.options.ticks.format),Se(t,i,l)},logarithmic(t,e,n){if(0===t)return"0";const i=n[e].significand||t/Math.pow(10,Math.floor(Ot(t)));return[1,2,3,5,10,15].includes(i)||e>.8*n.length?Te.numeric.call(this,t,e,n):""}};function De(t,e){let n=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;return Math.abs(n)>=1&&t!==Math.floor(t)&&(n=t-Math.floor(t)),n}var Ce={formatters:Te};function Ae(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:Ce.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}const Oe=Object.create(null),Pe=Object.create(null);function Ee(t,e){if(!e)return t;const n=e.split(".");for(let i=0,r=n.length;it.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>xe(e.backgroundColor),this.hoverBorderColor=(t,e)=>xe(e.borderColor),this.hoverColor=(t,e)=>xe(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return Re(this,t,e)}get(t){return Ee(this,t)}describe(t,e){return Re(Pe,t,e)}override(t,e){return Re(Oe,t,e)}route(t,e,n,i){const r=Ee(this,t),o=Ee(this,n),s="_"+e;Object.defineProperties(r,{[s]:{value:r[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[s],e=o[i];return J(t)?Object.assign({},e,t):et(t,e)},set(t){this[s]=t}}})}apply(t){t.forEach((t=>t(this)))}}var Le=new Ie({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[we,ke,Ae]);function ze(t){return!t||Z(t.size)||Z(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ne(t,e,n,i,r){let o=e[r];return o||(o=e[r]=t.measureText(r).width,n.push(r)),o>i&&(i=o),i}function Fe(t,e,n,i){i=i||{};let r=i.data=i.data||{},o=i.garbageCollect=i.garbageCollect||[];i.font!==e&&(r=i.data={},o=i.garbageCollect=[],i.font=e),t.save(),t.font=e;let s=0;const a=n.length;let l,c,u,h,d;for(l=0;ln.length){for(l=0;l0&&t.stroke()}}function Be(t,e,n){return n=n||.5,!e||t&&t.x>e.left-n&&t.xe.top-n&&t.y0&&""!==o.strokeColor;let l,c;for(t.save(),t.font=r.string,Xe(t,o),l=0;l+t||0;function rn(t,e){const n={},i=J(e),r=i?Object.keys(e):e,o=J(t)?i?n=>et(t[n],t[e[n]]):e=>t[e]:()=>t;for(const s of r)n[s]=nn(o(s));return n}function on(t){return rn(t,{top:"y",right:"x",bottom:"y",left:"x"})}function sn(t){return rn(t,["topLeft","topRight","bottomLeft","bottomRight"])}function an(t){const e=on(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function ln(t,e){t=t||{},e=e||Le.font;let n=et(t.size,e.size);"string"===typeof n&&(n=parseInt(n,10));let i=et(t.style,e.style);i&&!(""+i).match(tn)&&(console.warn('Invalid font style specified: "'+i+'"'),i=void 0);const r={family:et(t.family,e.family),lineHeight:en(et(t.lineHeight,e.lineHeight),n),size:n,style:i,weight:et(t.weight,e.weight),string:""};return r.string=ze(r),r}function cn(t,e,n,i){let r,o,s,a=!0;for(r=0,o=t.length;rn&&0===t?0:t+e;return{min:s(i,-Math.abs(o)),max:s(r,o)}}function hn(t,e){return Object.assign(Object.create(t),e)}function dn(t,e=[""],n,i,r=(()=>t[0])){const o=n||t;"undefined"===typeof i&&(i=Cn("_fallback",t));const s={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:i,_getTarget:r,override:n=>dn([n,...t],e,o,i)};return new Proxy(s,{deleteProperty(e,n){return delete e[n],delete e._keys,delete t[0][n],!0},get(n,i){return bn(n,i,(()=>Dn(i,e,t,n)))},getOwnPropertyDescriptor(t,e){return Reflect.getOwnPropertyDescriptor(t._scopes[0],e)},getPrototypeOf(){return Reflect.getPrototypeOf(t[0])},has(t,e){return An(t).includes(e)},ownKeys(t){return An(t)},set(t,e,n){const i=t._storage||(t._storage=r());return t[e]=i[e]=n,delete t._keys,!0}})}function fn(t,e,n,i){const r={_cacheable:!1,_proxy:t,_context:e,_subProxy:n,_stack:new Set,_descriptors:pn(t,i),setContext:e=>fn(t,e,n,i),override:r=>fn(t.override(r),e,n,i)};return new Proxy(r,{deleteProperty(e,n){return delete e[n],delete t[n],!0},get(t,e,n){return bn(t,e,(()=>xn(t,e,n)))},getOwnPropertyDescriptor(e,n){return e._descriptors.allKeys?Reflect.has(t,n)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,n)},getPrototypeOf(){return Reflect.getPrototypeOf(t)},has(e,n){return Reflect.has(t,n)},ownKeys(){return Reflect.ownKeys(t)},set(e,n,i){return t[n]=i,delete e[n],!0}})}function pn(t,e={scriptable:!0,indexable:!0}){const{_scriptable:n=e.scriptable,_indexable:i=e.indexable,_allKeys:r=e.allKeys}=t;return{allKeys:r,scriptable:n,indexable:i,isScriptable:yt(n)?n:()=>n,isIndexable:yt(i)?i:()=>i}}const gn=(t,e)=>t?t+bt(e):e,mn=(t,e)=>J(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function bn(t,e,n){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const i=n();return t[e]=i,i}function xn(t,e,n){const{_proxy:i,_context:r,_subProxy:o,_descriptors:s}=t;let a=i[e];return yt(a)&&s.isScriptable(e)&&(a=yn(e,a,t,n)),Q(a)&&a.length&&(a=vn(e,a,t,s.isIndexable)),mn(e,a)&&(a=fn(a,r,o&&o[e],s)),a}function yn(t,e,n,i){const{_proxy:r,_context:o,_subProxy:s,_stack:a}=n;if(a.has(t))throw new Error("Recursion detected: "+Array.from(a).join("->")+"->"+t);a.add(t);let l=e(o,s||i);return a.delete(t),mn(t,l)&&(l=Mn(r._scopes,r,t,l)),l}function vn(t,e,n,i){const{_proxy:r,_context:o,_subProxy:s,_descriptors:a}=n;if("undefined"!==typeof o.index&&i(t))return e[o.index%e.length];if(J(e[0])){const n=e,i=r._scopes.filter((t=>t!==n));e=[];for(const l of n){const n=Mn(i,r,t,l);e.push(fn(n,o,s&&s[t],a))}}return e}function wn(t,e,n){return yt(t)?t(e,n):t}const kn=(t,e)=>!0===t?e:"string"===typeof t?mt(e,t):void 0;function _n(t,e,n,i,r){for(const o of e){const e=kn(n,o);if(e){t.add(e);const o=wn(e._fallback,n,r);if("undefined"!==typeof o&&o!==n&&o!==i)return o}else if(!1===e&&"undefined"!==typeof i&&n!==i)return null}return!1}function Mn(t,e,n,i){const r=e._rootScopes,o=wn(e._fallback,n,i),s=[...t,...r],a=new Set;a.add(i);let l=Sn(a,s,n,o||n,i);return null!==l&&(("undefined"===typeof o||o===n||(l=Sn(a,s,o,l,i),null!==l))&&dn(Array.from(a),[""],r,o,(()=>Tn(e,n,i))))}function Sn(t,e,n,i,r){while(n)n=_n(t,e,n,i,r);return n}function Tn(t,e,n){const i=t._getTarget();e in i||(i[e]={});const r=i[e];return Q(r)&&J(n)?n:r||{}}function Dn(t,e,n,i){let r;for(const o of e)if(r=Cn(gn(o,t),n),"undefined"!==typeof r)return mn(t,r)?Mn(n,i,t,r):r}function Cn(t,e){for(const n of e){if(!n)continue;const e=n[t];if("undefined"!==typeof e)return e}}function An(t){let e=t._keys;return e||(e=t._keys=On(t._scopes)),e}function On(t){const e=new Set;for(const n of t)for(const t of Object.keys(n).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}function Pn(t,e,n,i){const{iScale:r}=t,{key:o="r"}=this._parsing,s=new Array(i);let a,l,c,u;for(a=0,l=i;ae"x"===t?"y":"x";function Ln(t,e,n,i){const r=t.skip?e:t,o=e,s=n.skip?e:n,a=Bt(o,r),l=Bt(s,o);let c=a/(a+l),u=l/(a+l);c=isNaN(c)?0:c,u=isNaN(u)?0:u;const h=i*c,d=i*u;return{previous:{x:o.x-h*(s.x-r.x),y:o.y-h*(s.y-r.y)},next:{x:o.x+d*(s.x-r.x),y:o.y+d*(s.y-r.y)}}}function zn(t,e,n){const i=t.length;let r,o,s,a,l,c=Rn(t,0);for(let u=0;u!t.skip))),"monotone"===e.cubicInterpolationMode)Fn(t,r);else{let n=i?t[t.length-1]:t[0];for(o=0,s=t.length;ot.ownerDocument.defaultView.getComputedStyle(t,null);function Un(t,e){return Vn(t).getPropertyValue(e)}const qn=["top","right","bottom","left"];function Xn(t,e,n){const i={};n=n?"-"+n:"";for(let r=0;r<4;r++){const o=qn[r];i[o]=parseFloat(t[e+"-"+o+n])||0}return i.width=i.left+i.right,i.height=i.top+i.bottom,i}const Gn=(t,e,n)=>(t>0||e>0)&&(!n||!n.shadowRoot);function Zn(t,e){const n=t.touches,i=n&&n.length?n[0]:t,{offsetX:r,offsetY:o}=i;let s,a,l=!1;if(Gn(r,o,t.target))s=r,a=o;else{const t=e.getBoundingClientRect();s=i.clientX-t.left,a=i.clientY-t.top,l=!0}return{x:s,y:a,box:l}}function Qn(t,e){if("native"in t)return t;const{canvas:n,currentDevicePixelRatio:i}=e,r=Vn(n),o="border-box"===r.boxSizing,s=Xn(r,"padding"),a=Xn(r,"border","width"),{x:l,y:c,box:u}=Zn(t,n),h=s.left+(u&&a.left),d=s.top+(u&&a.top);let{width:f,height:p}=e;return o&&(f-=s.width+a.width,p-=s.height+a.height),{x:Math.round((l-h)/f*n.width/i),y:Math.round((c-d)/p*n.height/i)}}function Jn(t,e,n){let i,r;if(void 0===e||void 0===n){const o=t&&Bn(t);if(o){const t=o.getBoundingClientRect(),s=Vn(o),a=Xn(s,"border","width"),l=Xn(s,"padding");e=t.width-l.width-a.width,n=t.height-l.height-a.height,i=Yn(s.maxWidth,o,"clientWidth"),r=Yn(s.maxHeight,o,"clientHeight")}else e=t.clientWidth,n=t.clientHeight}return{width:e,height:n,maxWidth:i||St,maxHeight:r||St}}const Kn=t=>Math.round(10*t)/10;function ti(t,e,n,i){const r=Vn(t),o=Xn(r,"margin"),s=Yn(r.maxWidth,t,"clientWidth")||St,a=Yn(r.maxHeight,t,"clientHeight")||St,l=Jn(t,e,n);let{width:c,height:u}=l;if("content-box"===r.boxSizing){const t=Xn(r,"border","width"),e=Xn(r,"padding");c-=e.width+t.width,u-=e.height+t.height}c=Math.max(0,c-o.width),u=Math.max(0,i?c/i:u-o.height),c=Kn(Math.min(c,s,l.maxWidth)),u=Kn(Math.min(u,a,l.maxHeight)),c&&!u&&(u=Kn(c/2));const h=void 0!==e||void 0!==n;return h&&i&&l.height&&u>l.height&&(u=l.height,c=Kn(Math.floor(u*i))),{width:c,height:u}}function ei(t,e,n){const i=e||1,r=Kn(t.height*i),o=Kn(t.width*i);t.height=Kn(t.height),t.width=Kn(t.width);const s=t.canvas;return s.style&&(n||!s.style.height&&!s.style.width)&&(s.style.height=`${t.height}px`,s.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==i||s.height!==r||s.width!==o)&&(t.currentDevicePixelRatio=i,s.height=r,s.width=o,t.ctx.setTransform(i,0,0,i,0,0),!0)}const ni=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};$n()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(e){}return t}();function ii(t,e){const n=Un(t,e),i=n&&n.match(/^(\d+)(\.\d+)?px$/);return i?+i[1]:void 0}function ri(t,e,n,i){return{x:t.x+n*(e.x-t.x),y:t.y+n*(e.y-t.y)}}function oi(t,e,n,i){return{x:t.x+n*(e.x-t.x),y:"middle"===i?n<.5?t.y:e.y:"after"===i?n<1?t.y:e.y:n>0?e.y:t.y}}function si(t,e,n,i){const r={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},s=ri(t,r,n),a=ri(r,o,n),l=ri(o,e,n),c=ri(s,a,n),u=ri(a,l,n);return ri(c,u,n)}const ai=function(t,e){return{x(n){return t+t+e-n},setWidth(t){e=t},textAlign(t){return"center"===t?t:"right"===t?"left":"right"},xPlus(t,e){return t-e},leftForLtr(t,e){return t-e}}},li=function(){return{x(t){return t},setWidth(t){},textAlign(t){return t},xPlus(t,e){return t+e},leftForLtr(t,e){return t}}};function ci(t,e,n){return t?ai(e,n):li()}function ui(t,e){let n,i;"ltr"!==e&&"rtl"!==e||(n=t.canvas.style,i=[n.getPropertyValue("direction"),n.getPropertyPriority("direction")],n.setProperty("direction",e,"important"),t.prevTextDirection=i)}function hi(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function di(t){return"angle"===t?{between:Ut,compare:Yt,normalize:Vt}:{between:Gt,compare:(t,e)=>t-e,normalize:t=>t}}function fi({start:t,end:e,count:n,loop:i,style:r}){return{start:t%n,end:e%n,loop:i&&(e-t+1)%n===0,style:r}}function pi(t,e,n){const{property:i,start:r,end:o}=n,{between:s,normalize:a}=di(i),l=e.length;let c,u,{start:h,end:d,loop:f}=t;if(f){for(h+=l,d+=l,c=0,u=l;cl(r,b,g)&&0!==a(r,b),w=()=>0===a(o,g)||l(o,b,g),k=()=>x||v(),_=()=>!x||w();for(let M=u,S=u;M<=h;++M)m=e[M%s],m.skip||(g=c(m[i]),g!==b&&(x=l(g,r,o),null===y&&k()&&(y=0===a(g,r)?M:S),null!==y&&_()&&(p.push(fi({start:y,end:M,loop:d,count:s,style:f})),y=null),S=M,b=g));return null!==y&&p.push(fi({start:y,end:h,loop:d,count:s,style:f})),p}function mi(t,e){const n=[],i=t.segments;for(let r=0;rr&&t[o%e].skip)o--;return o%=e,{start:r,end:o}}function xi(t,e,n,i){const r=t.length,o=[];let s,a=e,l=t[e];for(s=e+1;s<=n;++s){const n=t[s%r];n.skip||n.stop?l.skip||(i=!1,o.push({start:e%r,end:(s-1)%r,loop:i}),e=a=n.stop?s:null):(a=s,l.skip&&(e=s)),l=n}return null!==a&&o.push({start:e%r,end:a%r,loop:i}),o}function yi(t,e){const n=t.points,i=t.options.spanGaps,r=n.length;if(!r)return[];const o=!!t._loop,{start:s,end:a}=bi(n,r,o,i);if(!0===i)return vi(t,[{start:s,end:a,loop:o}],n,e);const l=a{let i;const r=d[t];return i="string"===typeof r?r:1===e?r.one:r.other.replace("{{count}}",e.toString()),n?.addSuffix?n.comparison&&n.comparison>0?"in "+i:i+" ago":i};function p(t){return(e={})=>{const n=e.width?String(e.width):t.defaultWidth,i=t.formats[n]||t.formats[t.defaultWidth];return i}}const g={full:"EEEE, MMMM do, y",long:"MMMM do, y",medium:"MMM d, y",short:"MM/dd/yyyy"},m={full:"h:mm:ss a zzzz",long:"h:mm:ss a z",medium:"h:mm:ss a",short:"h:mm a"},b={full:"{{date}} 'at' {{time}}",long:"{{date}} 'at' {{time}}",medium:"{{date}}, {{time}}",short:"{{date}}, {{time}}"},x={date:p({formats:g,defaultWidth:"full"}),time:p({formats:m,defaultWidth:"full"}),dateTime:p({formats:b,defaultWidth:"full"})},y={lastWeek:"'last' eeee 'at' p",yesterday:"'yesterday at' p",today:"'today at' p",tomorrow:"'tomorrow at' p",nextWeek:"eeee 'at' p",other:"P"},v=(t,e,n,i)=>y[t];function w(t){return(e,n)=>{const i=n?.context?String(n.context):"standalone";let r;if("formatting"===i&&t.formattingValues){const e=t.defaultFormattingWidth||t.defaultWidth,i=n?.width?String(n.width):e;r=t.formattingValues[i]||t.formattingValues[e]}else{const e=t.defaultWidth,i=n?.width?String(n.width):t.defaultWidth;r=t.values[i]||t.values[e]}const o=t.argumentCallback?t.argumentCallback(e):e;return r[o]}}const k={narrow:["B","A"],abbreviated:["BC","AD"],wide:["Before Christ","Anno Domini"]},_={narrow:["1","2","3","4"],abbreviated:["Q1","Q2","Q3","Q4"],wide:["1st quarter","2nd quarter","3rd quarter","4th quarter"]},M={narrow:["J","F","M","A","M","J","J","A","S","O","N","D"],abbreviated:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],wide:["January","February","March","April","May","June","July","August","September","October","November","December"]},S={narrow:["S","M","T","W","T","F","S"],short:["Su","Mo","Tu","We","Th","Fr","Sa"],abbreviated:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],wide:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},T={narrow:{am:"a",pm:"p",midnight:"mi",noon:"n",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},abbreviated:{am:"AM",pm:"PM",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},wide:{am:"a.m.",pm:"p.m.",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"}},D={narrow:{am:"a",pm:"p",midnight:"mi",noon:"n",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"},abbreviated:{am:"AM",pm:"PM",midnight:"midnight",noon:"noon",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"},wide:{am:"a.m.",pm:"p.m.",midnight:"midnight",noon:"noon",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"}},C=(t,e)=>{const n=Number(t),i=n%100;if(i>20||i<10)switch(i%10){case 1:return n+"st";case 2:return n+"nd";case 3:return n+"rd"}return n+"th"},A={ordinalNumber:C,era:w({values:k,defaultWidth:"wide"}),quarter:w({values:_,defaultWidth:"wide",argumentCallback:t=>t-1}),month:w({values:M,defaultWidth:"wide"}),day:w({values:S,defaultWidth:"wide"}),dayPeriod:w({values:T,defaultWidth:"wide",formattingValues:D,defaultFormattingWidth:"wide"})};function O(t){return(e,n={})=>{const i=n.width,r=i&&t.matchPatterns[i]||t.matchPatterns[t.defaultMatchWidth],o=e.match(r);if(!o)return null;const s=o[0],a=i&&t.parsePatterns[i]||t.parsePatterns[t.defaultParseWidth],l=Array.isArray(a)?E(a,(t=>t.test(s))):P(a,(t=>t.test(s)));let c;c=t.valueCallback?t.valueCallback(l):l,c=n.valueCallback?n.valueCallback(c):c;const u=e.slice(s.length);return{value:c,rest:u}}}function P(t,e){for(const n in t)if(Object.prototype.hasOwnProperty.call(t,n)&&e(t[n]))return n}function E(t,e){for(let n=0;n{const i=e.match(t.matchPattern);if(!i)return null;const r=i[0],o=e.match(t.parsePattern);if(!o)return null;let s=t.valueCallback?t.valueCallback(o[0]):o[0];s=n.valueCallback?n.valueCallback(s):s;const a=e.slice(r.length);return{value:s,rest:a}}}const I=/^(\d+)(th|st|nd|rd)?/i,L=/\d+/i,z={narrow:/^(b|a)/i,abbreviated:/^(b\.?\s?c\.?|b\.?\s?c\.?\s?e\.?|a\.?\s?d\.?|c\.?\s?e\.?)/i,wide:/^(before christ|before common era|anno domini|common era)/i},N={any:[/^b/i,/^(a|c)/i]},F={narrow:/^[1234]/i,abbreviated:/^q[1234]/i,wide:/^[1234](th|st|nd|rd)? quarter/i},j={any:[/1/i,/2/i,/3/i,/4/i]},H={narrow:/^[jfmasond]/i,abbreviated:/^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i,wide:/^(january|february|march|april|may|june|july|august|september|october|november|december)/i},W={narrow:[/^j/i,/^f/i,/^m/i,/^a/i,/^m/i,/^j/i,/^j/i,/^a/i,/^s/i,/^o/i,/^n/i,/^d/i],any:[/^ja/i,/^f/i,/^mar/i,/^ap/i,/^may/i,/^jun/i,/^jul/i,/^au/i,/^s/i,/^o/i,/^n/i,/^d/i]},$={narrow:/^[smtwf]/i,short:/^(su|mo|tu|we|th|fr|sa)/i,abbreviated:/^(sun|mon|tue|wed|thu|fri|sat)/i,wide:/^(sunday|monday|tuesday|wednesday|thursday|friday|saturday)/i},B={narrow:[/^s/i,/^m/i,/^t/i,/^w/i,/^t/i,/^f/i,/^s/i],any:[/^su/i,/^m/i,/^tu/i,/^w/i,/^th/i,/^f/i,/^sa/i]},Y={narrow:/^(a|p|mi|n|(in the|at) (morning|afternoon|evening|night))/i,any:/^([ap]\.?\s?m\.?|midnight|noon|(in the|at) (morning|afternoon|evening|night))/i},V={any:{am:/^a/i,pm:/^p/i,midnight:/^mi/i,noon:/^no/i,morning:/morning/i,afternoon:/afternoon/i,evening:/evening/i,night:/night/i}},U={ordinalNumber:R({matchPattern:I,parsePattern:L,valueCallback:t=>parseInt(t,10)}),era:O({matchPatterns:z,defaultMatchWidth:"wide",parsePatterns:N,defaultParseWidth:"any"}),quarter:O({matchPatterns:F,defaultMatchWidth:"wide",parsePatterns:j,defaultParseWidth:"any",valueCallback:t=>t+1}),month:O({matchPatterns:H,defaultMatchWidth:"wide",parsePatterns:W,defaultParseWidth:"any"}),day:O({matchPatterns:$,defaultMatchWidth:"wide",parsePatterns:B,defaultParseWidth:"any"}),dayPeriod:O({matchPatterns:Y,defaultMatchWidth:"any",parsePatterns:V,defaultParseWidth:"any"})},q={code:"en-US",formatDistance:f,formatLong:x,formatRelative:v,localize:A,match:U,options:{weekStartsOn:0,firstWeekContainsDate:1}};const X=(t,e)=>{switch(t){case"P":return e.date({width:"short"});case"PP":return e.date({width:"medium"});case"PPP":return e.date({width:"long"});case"PPPP":default:return e.date({width:"full"})}},G=(t,e)=>{switch(t){case"p":return e.time({width:"short"});case"pp":return e.time({width:"medium"});case"ppp":return e.time({width:"long"});case"pppp":default:return e.time({width:"full"})}},Z=(t,e)=>{const n=t.match(/(P+)(p+)?/)||[],i=n[1],r=n[2];if(!r)return X(t,e);let o;switch(i){case"P":o=e.dateTime({width:"short"});break;case"PP":o=e.dateTime({width:"medium"});break;case"PPP":o=e.dateTime({width:"long"});break;case"PPPP":default:o=e.dateTime({width:"full"});break}return o.replace("{{date}}",X(i,e)).replace("{{time}}",G(r,e))},Q={p:G,P:Z},J=/^D+$/,K=/^Y+$/,tt=["D","DD","YY","YYYY"];function et(t){return J.test(t)}function nt(t){return K.test(t)}function it(t,e,n){const i=rt(t,e,n);if(console.warn(i),tt.includes(t))throw new RangeError(i)}function rt(t,e,n){const i="Y"===t[0]?"years":"days of the month";return`Use \`${t.toLowerCase()}\` instead of \`${t}\` (in \`${e}\`) for formatting ${i} to the input \`${n}\`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md`}let ot={};function st(){return ot}function at(){return Object.assign({},st())}function lt(t,e){const n=ct(e)?new e(0):u(e,0);return n.setFullYear(t.getFullYear(),t.getMonth(),t.getDate()),n.setHours(t.getHours(),t.getMinutes(),t.getSeconds(),t.getMilliseconds()),n}function ct(t){return"function"===typeof t&&t.prototype?.constructor===t}const ut=10;class ht{subPriority=0;validate(t,e){return!0}}class dt extends ht{constructor(t,e,n,i,r){super(),this.value=t,this.validateValue=e,this.setValue=n,this.priority=i,r&&(this.subPriority=r)}validate(t,e){return this.validateValue(t,this.value,e)}set(t,e,n){return this.setValue(t,e,this.value,n)}}class ft extends ht{priority=ut;subPriority=-1;constructor(t,e){super(),this.context=t||(t=>u(e,t))}set(t,e){return e.timestampIsSet?t:u(t,lt(t,this.context))}}class pt{run(t,e,n,i){const r=this.parse(t,e,n,i);return r?{setter:new dt(r.value,this.validate,this.set,this.priority,this.subPriority),rest:r.rest}:null}validate(t,e,n){return!0}}class gt extends pt{priority=140;parse(t,e,n){switch(e){case"G":case"GG":case"GGG":return n.era(t,{width:"abbreviated"})||n.era(t,{width:"narrow"});case"GGGGG":return n.era(t,{width:"narrow"});case"GGGG":default:return n.era(t,{width:"wide"})||n.era(t,{width:"abbreviated"})||n.era(t,{width:"narrow"})}}set(t,e,n){return e.era=n,t.setFullYear(n,0,1),t.setHours(0,0,0,0),t}incompatibleTokens=["R","u","t","T"]}const mt={month:/^(1[0-2]|0?\d)/,date:/^(3[0-1]|[0-2]?\d)/,dayOfYear:/^(36[0-6]|3[0-5]\d|[0-2]?\d?\d)/,week:/^(5[0-3]|[0-4]?\d)/,hour23h:/^(2[0-3]|[0-1]?\d)/,hour24h:/^(2[0-4]|[0-1]?\d)/,hour11h:/^(1[0-1]|0?\d)/,hour12h:/^(1[0-2]|0?\d)/,minute:/^[0-5]?\d/,second:/^[0-5]?\d/,singleDigit:/^\d/,twoDigits:/^\d{1,2}/,threeDigits:/^\d{1,3}/,fourDigits:/^\d{1,4}/,anyDigitsSigned:/^-?\d+/,singleDigitSigned:/^-?\d/,twoDigitsSigned:/^-?\d{1,2}/,threeDigitsSigned:/^-?\d{1,3}/,fourDigitsSigned:/^-?\d{1,4}/},bt={basicOptionalMinutes:/^([+-])(\d{2})(\d{2})?|Z/,basic:/^([+-])(\d{2})(\d{2})|Z/,basicOptionalSeconds:/^([+-])(\d{2})(\d{2})((\d{2}))?|Z/,extended:/^([+-])(\d{2}):(\d{2})|Z/,extendedOptionalSeconds:/^([+-])(\d{2}):(\d{2})(:(\d{2}))?|Z/};function xt(t,e){return t?{value:e(t.value),rest:t.rest}:t}function yt(t,e){const n=e.match(t);return n?{value:parseInt(n[0],10),rest:e.slice(n[0].length)}:null}function vt(t,e){const n=e.match(t);if(!n)return null;if("Z"===n[0])return{value:0,rest:e.slice(1)};const i="+"===n[1]?1:-1,r=n[2]?parseInt(n[2],10):0,o=n[3]?parseInt(n[3],10):0,c=n[5]?parseInt(n[5],10):0;return{value:i*(r*a+o*s+c*l),rest:e.slice(n[0].length)}}function wt(t){return yt(mt.anyDigitsSigned,t)}function kt(t,e){switch(t){case 1:return yt(mt.singleDigit,e);case 2:return yt(mt.twoDigits,e);case 3:return yt(mt.threeDigits,e);case 4:return yt(mt.fourDigits,e);default:return yt(new RegExp("^\\d{1,"+t+"}"),e)}}function _t(t,e){switch(t){case 1:return yt(mt.singleDigitSigned,e);case 2:return yt(mt.twoDigitsSigned,e);case 3:return yt(mt.threeDigitsSigned,e);case 4:return yt(mt.fourDigitsSigned,e);default:return yt(new RegExp("^-?\\d{1,"+t+"}"),e)}}function Mt(t){switch(t){case"morning":return 4;case"evening":return 17;case"pm":case"noon":case"afternoon":return 12;case"am":case"midnight":case"night":default:return 0}}function St(t,e){const n=e>0,i=n?e:1-e;let r;if(i<=50)r=t||100;else{const e=i+50,n=100*Math.trunc(e/100),o=t>=e%100;r=t+n-(o?100:0)}return n?r:1-r}function Tt(t){return t%400===0||t%4===0&&t%100!==0}class Dt extends pt{priority=130;incompatibleTokens=["Y","R","u","w","I","i","e","c","t","T"];parse(t,e,n){const i=t=>({year:t,isTwoDigitYear:"yy"===e});switch(e){case"y":return xt(kt(4,t),i);case"yo":return xt(n.ordinalNumber(t,{unit:"year"}),i);default:return xt(kt(e.length,t),i)}}validate(t,e){return e.isTwoDigitYear||e.year>0}set(t,e,n){const i=t.getFullYear();if(n.isTwoDigitYear){const e=St(n.year,i);return t.setFullYear(e,0,1),t.setHours(0,0,0,0),t}const r="era"in e&&1!==e.era?1-n.year:n.year;return t.setFullYear(r,0,1),t.setHours(0,0,0,0),t}}function Ct(t,e){const n=st(),i=e?.weekStartsOn??e?.locale?.options?.weekStartsOn??n.weekStartsOn??n.locale?.options?.weekStartsOn??0,r=h(t,e?.in),o=r.getDay(),s=(o=+a?i+1:+n>=+c?i:i-1}class Ot extends pt{priority=130;parse(t,e,n){const i=t=>({year:t,isTwoDigitYear:"YY"===e});switch(e){case"Y":return xt(kt(4,t),i);case"Yo":return xt(n.ordinalNumber(t,{unit:"year"}),i);default:return xt(kt(e.length,t),i)}}validate(t,e){return e.isTwoDigitYear||e.year>0}set(t,e,n,i){const r=At(t,i);if(n.isTwoDigitYear){const e=St(n.year,r);return t.setFullYear(e,0,i.firstWeekContainsDate),t.setHours(0,0,0,0),Ct(t,i)}const o="era"in e&&1!==e.era?1-n.year:n.year;return t.setFullYear(o,0,i.firstWeekContainsDate),t.setHours(0,0,0,0),Ct(t,i)}incompatibleTokens=["y","R","u","Q","q","M","L","I","d","D","i","t","T"]}function Pt(t,e){return Ct(t,{...e,weekStartsOn:1})}class Et extends pt{priority=130;parse(t,e){return _t("R"===e?4:e.length,t)}set(t,e,n){const i=u(t,0);return i.setFullYear(n,0,4),i.setHours(0,0,0,0),Pt(i)}incompatibleTokens=["G","y","Y","u","Q","q","M","L","w","d","D","e","c","t","T"]}class Rt extends pt{priority=130;parse(t,e){return _t("u"===e?4:e.length,t)}set(t,e,n){return t.setFullYear(n,0,1),t.setHours(0,0,0,0),t}incompatibleTokens=["G","y","Y","R","w","I","i","e","c","t","T"]}class It extends pt{priority=120;parse(t,e,n){switch(e){case"Q":case"QQ":return kt(e.length,t);case"Qo":return n.ordinalNumber(t,{unit:"quarter"});case"QQQ":return n.quarter(t,{width:"abbreviated",context:"formatting"})||n.quarter(t,{width:"narrow",context:"formatting"});case"QQQQQ":return n.quarter(t,{width:"narrow",context:"formatting"});case"QQQQ":default:return n.quarter(t,{width:"wide",context:"formatting"})||n.quarter(t,{width:"abbreviated",context:"formatting"})||n.quarter(t,{width:"narrow",context:"formatting"})}}validate(t,e){return e>=1&&e<=4}set(t,e,n){return t.setMonth(3*(n-1),1),t.setHours(0,0,0,0),t}incompatibleTokens=["Y","R","q","M","L","w","I","d","D","i","e","c","t","T"]}class Lt extends pt{priority=120;parse(t,e,n){switch(e){case"q":case"qq":return kt(e.length,t);case"qo":return n.ordinalNumber(t,{unit:"quarter"});case"qqq":return n.quarter(t,{width:"abbreviated",context:"standalone"})||n.quarter(t,{width:"narrow",context:"standalone"});case"qqqqq":return n.quarter(t,{width:"narrow",context:"standalone"});case"qqqq":default:return n.quarter(t,{width:"wide",context:"standalone"})||n.quarter(t,{width:"abbreviated",context:"standalone"})||n.quarter(t,{width:"narrow",context:"standalone"})}}validate(t,e){return e>=1&&e<=4}set(t,e,n){return t.setMonth(3*(n-1),1),t.setHours(0,0,0,0),t}incompatibleTokens=["Y","R","Q","M","L","w","I","d","D","i","e","c","t","T"]}class zt extends pt{incompatibleTokens=["Y","R","q","Q","L","w","I","D","i","e","c","t","T"];priority=110;parse(t,e,n){const i=t=>t-1;switch(e){case"M":return xt(yt(mt.month,t),i);case"MM":return xt(kt(2,t),i);case"Mo":return xt(n.ordinalNumber(t,{unit:"month"}),i);case"MMM":return n.month(t,{width:"abbreviated",context:"formatting"})||n.month(t,{width:"narrow",context:"formatting"});case"MMMMM":return n.month(t,{width:"narrow",context:"formatting"});case"MMMM":default:return n.month(t,{width:"wide",context:"formatting"})||n.month(t,{width:"abbreviated",context:"formatting"})||n.month(t,{width:"narrow",context:"formatting"})}}validate(t,e){return e>=0&&e<=11}set(t,e,n){return t.setMonth(n,1),t.setHours(0,0,0,0),t}}class Nt extends pt{priority=110;parse(t,e,n){const i=t=>t-1;switch(e){case"L":return xt(yt(mt.month,t),i);case"LL":return xt(kt(2,t),i);case"Lo":return xt(n.ordinalNumber(t,{unit:"month"}),i);case"LLL":return n.month(t,{width:"abbreviated",context:"standalone"})||n.month(t,{width:"narrow",context:"standalone"});case"LLLLL":return n.month(t,{width:"narrow",context:"standalone"});case"LLLL":default:return n.month(t,{width:"wide",context:"standalone"})||n.month(t,{width:"abbreviated",context:"standalone"})||n.month(t,{width:"narrow",context:"standalone"})}}validate(t,e){return e>=0&&e<=11}set(t,e,n){return t.setMonth(n,1),t.setHours(0,0,0,0),t}incompatibleTokens=["Y","R","q","Q","M","w","I","D","i","e","c","t","T"]}function Ft(t,e){const n=st(),i=e?.firstWeekContainsDate??e?.locale?.options?.firstWeekContainsDate??n.firstWeekContainsDate??n.locale?.options?.firstWeekContainsDate??1,r=At(t,e),o=u(e?.in||t,0);o.setFullYear(r,0,i),o.setHours(0,0,0,0);const s=Ct(o,e);return s}function jt(t,e){const n=h(t,e?.in),i=+Ct(n,e)-+Ft(n,e);return Math.round(i/r)+1}function Ht(t,e,n){const i=h(t,n?.in),r=jt(i,n)-e;return i.setDate(i.getDate()-7*r),h(i,n?.in)}class Wt extends pt{priority=100;parse(t,e,n){switch(e){case"w":return yt(mt.week,t);case"wo":return n.ordinalNumber(t,{unit:"week"});default:return kt(e.length,t)}}validate(t,e){return e>=1&&e<=53}set(t,e,n,i){return Ct(Ht(t,n,i),i)}incompatibleTokens=["y","R","u","q","Q","M","L","I","d","D","i","t","T"]}function $t(t,e){const n=h(t,e?.in),i=n.getFullYear(),r=u(n,0);r.setFullYear(i+1,0,4),r.setHours(0,0,0,0);const o=Pt(r),s=u(n,0);s.setFullYear(i,0,4),s.setHours(0,0,0,0);const a=Pt(s);return n.getTime()>=o.getTime()?i+1:n.getTime()>=a.getTime()?i:i-1}function Bt(t,e){const n=$t(t,e),i=u(e?.in||t,0);return i.setFullYear(n,0,4),i.setHours(0,0,0,0),Pt(i)}function Yt(t,e){const n=h(t,e?.in),i=+Pt(n)-+Bt(n);return Math.round(i/r)+1}function Vt(t,e,n){const i=h(t,n?.in),r=Yt(i,n)-e;return i.setDate(i.getDate()-7*r),i}class Ut extends pt{priority=100;parse(t,e,n){switch(e){case"I":return yt(mt.week,t);case"Io":return n.ordinalNumber(t,{unit:"week"});default:return kt(e.length,t)}}validate(t,e){return e>=1&&e<=53}set(t,e,n){return Pt(Vt(t,n))}incompatibleTokens=["y","Y","u","q","Q","M","L","w","d","D","e","c","t","T"]}const qt=[31,28,31,30,31,30,31,31,30,31,30,31],Xt=[31,29,31,30,31,30,31,31,30,31,30,31];class Gt extends pt{priority=90;subPriority=1;parse(t,e,n){switch(e){case"d":return yt(mt.date,t);case"do":return n.ordinalNumber(t,{unit:"date"});default:return kt(e.length,t)}}validate(t,e){const n=t.getFullYear(),i=Tt(n),r=t.getMonth();return i?e>=1&&e<=Xt[r]:e>=1&&e<=qt[r]}set(t,e,n){return t.setDate(n),t.setHours(0,0,0,0),t}incompatibleTokens=["Y","R","q","Q","w","I","D","i","e","c","t","T"]}class Zt extends pt{priority=90;subpriority=1;parse(t,e,n){switch(e){case"D":case"DD":return yt(mt.dayOfYear,t);case"Do":return n.ordinalNumber(t,{unit:"date"});default:return kt(e.length,t)}}validate(t,e){const n=t.getFullYear(),i=Tt(n);return i?e>=1&&e<=366:e>=1&&e<=365}set(t,e,n){return t.setMonth(0,n),t.setHours(0,0,0,0),t}incompatibleTokens=["Y","R","q","Q","M","L","w","I","d","E","i","e","c","t","T"]}function Qt(t,e,n){const i=h(t,n?.in);return isNaN(e)?u(n?.in||t,NaN):e?(i.setDate(i.getDate()+e),i):i}function Jt(t,e,n){const i=st(),r=n?.weekStartsOn??n?.locale?.options?.weekStartsOn??i.weekStartsOn??i.locale?.options?.weekStartsOn??0,o=h(t,n?.in),s=o.getDay(),a=e%7,l=(a+7)%7,c=7-r,u=e<0||e>6?e-(s+c)%7:(l+c)%7-(s+c)%7;return Qt(o,u,n)}class Kt extends pt{priority=90;parse(t,e,n){switch(e){case"E":case"EE":case"EEE":return n.day(t,{width:"abbreviated",context:"formatting"})||n.day(t,{width:"short",context:"formatting"})||n.day(t,{width:"narrow",context:"formatting"});case"EEEEE":return n.day(t,{width:"narrow",context:"formatting"});case"EEEEEE":return n.day(t,{width:"short",context:"formatting"})||n.day(t,{width:"narrow",context:"formatting"});case"EEEE":default:return n.day(t,{width:"wide",context:"formatting"})||n.day(t,{width:"abbreviated",context:"formatting"})||n.day(t,{width:"short",context:"formatting"})||n.day(t,{width:"narrow",context:"formatting"})}}validate(t,e){return e>=0&&e<=6}set(t,e,n,i){return t=Jt(t,n,i),t.setHours(0,0,0,0),t}incompatibleTokens=["D","i","e","c","t","T"]}class te extends pt{priority=90;parse(t,e,n,i){const r=t=>{const e=7*Math.floor((t-1)/7);return(t+i.weekStartsOn+6)%7+e};switch(e){case"e":case"ee":return xt(kt(e.length,t),r);case"eo":return xt(n.ordinalNumber(t,{unit:"day"}),r);case"eee":return n.day(t,{width:"abbreviated",context:"formatting"})||n.day(t,{width:"short",context:"formatting"})||n.day(t,{width:"narrow",context:"formatting"});case"eeeee":return n.day(t,{width:"narrow",context:"formatting"});case"eeeeee":return n.day(t,{width:"short",context:"formatting"})||n.day(t,{width:"narrow",context:"formatting"});case"eeee":default:return n.day(t,{width:"wide",context:"formatting"})||n.day(t,{width:"abbreviated",context:"formatting"})||n.day(t,{width:"short",context:"formatting"})||n.day(t,{width:"narrow",context:"formatting"})}}validate(t,e){return e>=0&&e<=6}set(t,e,n,i){return t=Jt(t,n,i),t.setHours(0,0,0,0),t}incompatibleTokens=["y","R","u","q","Q","M","L","I","d","D","E","i","c","t","T"]}class ee extends pt{priority=90;parse(t,e,n,i){const r=t=>{const e=7*Math.floor((t-1)/7);return(t+i.weekStartsOn+6)%7+e};switch(e){case"c":case"cc":return xt(kt(e.length,t),r);case"co":return xt(n.ordinalNumber(t,{unit:"day"}),r);case"ccc":return n.day(t,{width:"abbreviated",context:"standalone"})||n.day(t,{width:"short",context:"standalone"})||n.day(t,{width:"narrow",context:"standalone"});case"ccccc":return n.day(t,{width:"narrow",context:"standalone"});case"cccccc":return n.day(t,{width:"short",context:"standalone"})||n.day(t,{width:"narrow",context:"standalone"});case"cccc":default:return n.day(t,{width:"wide",context:"standalone"})||n.day(t,{width:"abbreviated",context:"standalone"})||n.day(t,{width:"short",context:"standalone"})||n.day(t,{width:"narrow",context:"standalone"})}}validate(t,e){return e>=0&&e<=6}set(t,e,n,i){return t=Jt(t,n,i),t.setHours(0,0,0,0),t}incompatibleTokens=["y","R","u","q","Q","M","L","I","d","D","E","i","e","t","T"]}function ne(t,e){const n=h(t,e?.in).getDay();return 0===n?7:n}function ie(t,e,n){const i=h(t,n?.in),r=ne(i,n),o=e-r;return Qt(i,o,n)}class re extends pt{priority=90;parse(t,e,n){const i=t=>0===t?7:t;switch(e){case"i":case"ii":return kt(e.length,t);case"io":return n.ordinalNumber(t,{unit:"day"});case"iii":return xt(n.day(t,{width:"abbreviated",context:"formatting"})||n.day(t,{width:"short",context:"formatting"})||n.day(t,{width:"narrow",context:"formatting"}),i);case"iiiii":return xt(n.day(t,{width:"narrow",context:"formatting"}),i);case"iiiiii":return xt(n.day(t,{width:"short",context:"formatting"})||n.day(t,{width:"narrow",context:"formatting"}),i);case"iiii":default:return xt(n.day(t,{width:"wide",context:"formatting"})||n.day(t,{width:"abbreviated",context:"formatting"})||n.day(t,{width:"short",context:"formatting"})||n.day(t,{width:"narrow",context:"formatting"}),i)}}validate(t,e){return e>=1&&e<=7}set(t,e,n){return t=ie(t,n),t.setHours(0,0,0,0),t}incompatibleTokens=["y","Y","u","q","Q","M","L","w","d","D","E","e","c","t","T"]}class oe extends pt{priority=80;parse(t,e,n){switch(e){case"a":case"aa":case"aaa":return n.dayPeriod(t,{width:"abbreviated",context:"formatting"})||n.dayPeriod(t,{width:"narrow",context:"formatting"});case"aaaaa":return n.dayPeriod(t,{width:"narrow",context:"formatting"});case"aaaa":default:return n.dayPeriod(t,{width:"wide",context:"formatting"})||n.dayPeriod(t,{width:"abbreviated",context:"formatting"})||n.dayPeriod(t,{width:"narrow",context:"formatting"})}}set(t,e,n){return t.setHours(Mt(n),0,0,0),t}incompatibleTokens=["b","B","H","k","t","T"]}class se extends pt{priority=80;parse(t,e,n){switch(e){case"b":case"bb":case"bbb":return n.dayPeriod(t,{width:"abbreviated",context:"formatting"})||n.dayPeriod(t,{width:"narrow",context:"formatting"});case"bbbbb":return n.dayPeriod(t,{width:"narrow",context:"formatting"});case"bbbb":default:return n.dayPeriod(t,{width:"wide",context:"formatting"})||n.dayPeriod(t,{width:"abbreviated",context:"formatting"})||n.dayPeriod(t,{width:"narrow",context:"formatting"})}}set(t,e,n){return t.setHours(Mt(n),0,0,0),t}incompatibleTokens=["a","B","H","k","t","T"]}class ae extends pt{priority=80;parse(t,e,n){switch(e){case"B":case"BB":case"BBB":return n.dayPeriod(t,{width:"abbreviated",context:"formatting"})||n.dayPeriod(t,{width:"narrow",context:"formatting"});case"BBBBB":return n.dayPeriod(t,{width:"narrow",context:"formatting"});case"BBBB":default:return n.dayPeriod(t,{width:"wide",context:"formatting"})||n.dayPeriod(t,{width:"abbreviated",context:"formatting"})||n.dayPeriod(t,{width:"narrow",context:"formatting"})}}set(t,e,n){return t.setHours(Mt(n),0,0,0),t}incompatibleTokens=["a","b","t","T"]}class le extends pt{priority=70;parse(t,e,n){switch(e){case"h":return yt(mt.hour12h,t);case"ho":return n.ordinalNumber(t,{unit:"hour"});default:return kt(e.length,t)}}validate(t,e){return e>=1&&e<=12}set(t,e,n){const i=t.getHours()>=12;return i&&n<12?t.setHours(n+12,0,0,0):i||12!==n?t.setHours(n,0,0,0):t.setHours(0,0,0,0),t}incompatibleTokens=["H","K","k","t","T"]}class ce extends pt{priority=70;parse(t,e,n){switch(e){case"H":return yt(mt.hour23h,t);case"Ho":return n.ordinalNumber(t,{unit:"hour"});default:return kt(e.length,t)}}validate(t,e){return e>=0&&e<=23}set(t,e,n){return t.setHours(n,0,0,0),t}incompatibleTokens=["a","b","h","K","k","t","T"]}class ue extends pt{priority=70;parse(t,e,n){switch(e){case"K":return yt(mt.hour11h,t);case"Ko":return n.ordinalNumber(t,{unit:"hour"});default:return kt(e.length,t)}}validate(t,e){return e>=0&&e<=11}set(t,e,n){const i=t.getHours()>=12;return i&&n<12?t.setHours(n+12,0,0,0):t.setHours(n,0,0,0),t}incompatibleTokens=["h","H","k","t","T"]}class he extends pt{priority=70;parse(t,e,n){switch(e){case"k":return yt(mt.hour24h,t);case"ko":return n.ordinalNumber(t,{unit:"hour"});default:return kt(e.length,t)}}validate(t,e){return e>=1&&e<=24}set(t,e,n){const i=n<=24?n%24:n;return t.setHours(i,0,0,0),t}incompatibleTokens=["a","b","h","H","K","t","T"]}class de extends pt{priority=60;parse(t,e,n){switch(e){case"m":return yt(mt.minute,t);case"mo":return n.ordinalNumber(t,{unit:"minute"});default:return kt(e.length,t)}}validate(t,e){return e>=0&&e<=59}set(t,e,n){return t.setMinutes(n,0,0),t}incompatibleTokens=["t","T"]}class fe extends pt{priority=50;parse(t,e,n){switch(e){case"s":return yt(mt.second,t);case"so":return n.ordinalNumber(t,{unit:"second"});default:return kt(e.length,t)}}validate(t,e){return e>=0&&e<=59}set(t,e,n){return t.setSeconds(n,0),t}incompatibleTokens=["t","T"]}class pe extends pt{priority=30;parse(t,e){const n=t=>Math.trunc(t*Math.pow(10,3-e.length));return xt(kt(e.length,t),n)}set(t,e,n){return t.setMilliseconds(n),t}incompatibleTokens=["t","T"]}function ge(t){const e=h(t),n=new Date(Date.UTC(e.getFullYear(),e.getMonth(),e.getDate(),e.getHours(),e.getMinutes(),e.getSeconds(),e.getMilliseconds()));return n.setUTCFullYear(e.getFullYear()),+t-+n}class me extends pt{priority=10;parse(t,e){switch(e){case"X":return vt(bt.basicOptionalMinutes,t);case"XX":return vt(bt.basic,t);case"XXXX":return vt(bt.basicOptionalSeconds,t);case"XXXXX":return vt(bt.extendedOptionalSeconds,t);case"XXX":default:return vt(bt.extended,t)}}set(t,e,n){return e.timestampIsSet?t:u(t,t.getTime()-ge(t)-n)}incompatibleTokens=["t","T","x"]}class be extends pt{priority=10;parse(t,e){switch(e){case"x":return vt(bt.basicOptionalMinutes,t);case"xx":return vt(bt.basic,t);case"xxxx":return vt(bt.basicOptionalSeconds,t);case"xxxxx":return vt(bt.extendedOptionalSeconds,t);case"xxx":default:return vt(bt.extended,t)}}set(t,e,n){return e.timestampIsSet?t:u(t,t.getTime()-ge(t)-n)}incompatibleTokens=["t","T","X"]}class xe extends pt{priority=40;parse(t){return wt(t)}set(t,e,n){return[u(t,1e3*n),{timestampIsSet:!0}]}incompatibleTokens="*"}class ye extends pt{priority=20;parse(t){return wt(t)}set(t,e,n){return[u(t,n),{timestampIsSet:!0}]}incompatibleTokens="*"}const ve={G:new gt,y:new Dt,Y:new Ot,R:new Et,u:new Rt,Q:new It,q:new Lt,M:new zt,L:new Nt,w:new Wt,I:new Ut,d:new Gt,D:new Zt,E:new Kt,e:new te,c:new ee,i:new re,a:new oe,b:new se,B:new ae,h:new le,H:new ce,K:new ue,k:new he,m:new de,s:new fe,S:new pe,X:new me,x:new be,t:new xe,T:new ye},we=/[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g,ke=/P+p+|P+|p+|''|'(''|[^'])+('|$)|./g,_e=/^'([^]*?)'?$/,Me=/''/g,Se=/\S/,Te=/[a-zA-Z]/;function De(t,e,n,i){const r=()=>u(i?.in||n,NaN),o=at(),s=i?.locale??o.locale??q,a=i?.firstWeekContainsDate??i?.locale?.options?.firstWeekContainsDate??o.firstWeekContainsDate??o.locale?.options?.firstWeekContainsDate??1,l=i?.weekStartsOn??i?.locale?.options?.weekStartsOn??o.weekStartsOn??o.locale?.options?.weekStartsOn??0;if(!e)return t?r():h(n,i?.in);const c={firstWeekContainsDate:a,weekStartsOn:l,locale:s},d=[new ft(i?.in,n)],f=e.match(ke).map((t=>{const e=t[0];if(e in Q){const n=Q[e];return n(t,s.formatLong)}return t})).join("").match(we),p=[];for(let u of f){!i?.useAdditionalWeekYearTokens&&nt(u)&&it(u,e,t),!i?.useAdditionalDayOfYearTokens&&et(u)&&it(u,e,t);const n=u[0],o=ve[n];if(o){const{incompatibleTokens:e}=o;if(Array.isArray(e)){const t=p.find((t=>e.includes(t.token)||t.token===n));if(t)throw new RangeError(`The format string mustn't contain \`${t.fullToken}\` and \`${u}\` at the same time`)}else if("*"===o.incompatibleTokens&&p.length>0)throw new RangeError(`The format string mustn't contain \`${u}\` and any other token at the same time`);p.push({token:n,fullToken:u});const i=o.run(t,u,s.match,c);if(!i)return r();d.push(i.setter),t=i.rest}else{if(n.match(Te))throw new RangeError("Format string contains an unescaped latin alphabet character `"+n+"`");if("''"===u?u="'":"'"===n&&(u=Ce(u)),0!==t.indexOf(u))return r();t=t.slice(u.length)}}if(t.length>0&&Se.test(t))return r();const g=d.map((t=>t.priority)).sort(((t,e)=>e-t)).filter(((t,e,n)=>n.indexOf(t)===e)).map((t=>d.filter((e=>e.priority===t)).sort(((t,e)=>e.subPriority-t.subPriority)))).map((t=>t[0]));let m=h(n,i?.in);if(isNaN(+m))return r();const b={};for(const u of g){if(!u.validate(m,c))return r();const t=u.set(m,b,c);Array.isArray(t)?(m=t[0],Object.assign(b,t[1])):m=t}return m}function Ce(t){return t.match(_e)[1].replace(Me,"'")}function Ae(t,e){const n=()=>u(e?.in,NaN),i=e?.additionalDigits??2,r=Ie(t);let o;if(r.date){const t=Le(r.date,i);o=ze(t.restDateString,t.year)}if(!o||isNaN(+o))return n();const s=+o;let a,l=0;if(r.time&&(l=Fe(r.time),isNaN(l)))return n();if(!r.timezone){const t=new Date(s+l),n=h(0,e?.in);return n.setFullYear(t.getUTCFullYear(),t.getUTCMonth(),t.getUTCDate()),n.setHours(t.getUTCHours(),t.getUTCMinutes(),t.getUTCSeconds(),t.getUTCMilliseconds()),n}return a=He(r.timezone),isNaN(a)?n():h(s+l+a,e?.in)}const Oe={dateTimeDelimiter:/[T ]/,timeZoneDelimiter:/[Z ]/i,timezone:/([Z+-].*)$/},Pe=/^-?(?:(\d{3})|(\d{2})(?:-?(\d{2}))?|W(\d{2})(?:-?(\d{1}))?|)$/,Ee=/^(\d{2}(?:[.,]\d*)?)(?::?(\d{2}(?:[.,]\d*)?))?(?::?(\d{2}(?:[.,]\d*)?))?$/,Re=/^([+-])(\d{2})(?::?(\d{2}))?$/;function Ie(t){const e={},n=t.split(Oe.dateTimeDelimiter);let i;if(n.length>2)return e;if(/:/.test(n[0])?i=n[0]:(e.date=n[0],i=n[1],Oe.timeZoneDelimiter.test(e.date)&&(e.date=t.split(Oe.timeZoneDelimiter)[0],i=t.substr(e.date.length,t.length))),i){const t=Oe.timezone.exec(i);t?(e.time=i.replace(t[1],""),e.timezone=t[1]):e.time=i}return e}function Le(t,e){const n=new RegExp("^(?:(\\d{4}|[+-]\\d{"+(4+e)+"})|(\\d{2}|[+-]\\d{"+(2+e)+"})$)"),i=t.match(n);if(!i)return{year:NaN,restDateString:""};const r=i[1]?parseInt(i[1]):null,o=i[2]?parseInt(i[2]):null;return{year:null===o?r:100*o,restDateString:t.slice((i[1]||i[2]).length)}}function ze(t,e){if(null===e)return new Date(NaN);const n=t.match(Pe);if(!n)return new Date(NaN);const i=!!n[4],r=Ne(n[1]),o=Ne(n[2])-1,s=Ne(n[3]),a=Ne(n[4]),l=Ne(n[5])-1;if(i)return Ue(e,a,l)?We(e,a,l):new Date(NaN);{const t=new Date(0);return Ye(e,o,s)&&Ve(e,r)?(t.setUTCFullYear(e,o,Math.max(r,s)),t):new Date(NaN)}}function Ne(t){return t?parseInt(t):1}function Fe(t){const e=t.match(Ee);if(!e)return NaN;const n=je(e[1]),i=je(e[2]),r=je(e[3]);return qe(n,i,r)?n*a+i*s+1e3*r:NaN}function je(t){return t&&parseFloat(t.replace(",","."))||0}function He(t){if("Z"===t)return 0;const e=t.match(Re);if(!e)return 0;const n="+"===e[1]?-1:1,i=parseInt(e[2]),r=e[3]&&parseInt(e[3])||0;return Xe(i,r)?n*(i*a+r*s):NaN}function We(t,e,n){const i=new Date(0);i.setUTCFullYear(t,0,4);const r=i.getUTCDay()||7,o=7*(e-1)+n+1-r;return i.setUTCDate(i.getUTCDate()+o),i}const $e=[31,null,31,30,31,30,31,31,30,31,30,31];function Be(t){return t%400===0||t%4===0&&t%100!==0}function Ye(t,e,n){return e>=0&&e<=11&&n>=1&&n<=($e[e]||(Be(t)?29:28))}function Ve(t,e){return e>=1&&e<=(Be(t)?366:365)}function Ue(t,e,n){return e>=1&&e<=53&&n>=0&&n<=6}function qe(t,e,n){return 24===t?0===e&&0===n:n>=0&&n<60&&e>=0&&e<60&&t>=0&&t<25}function Xe(t,e){return e>=0&&e<=59}function Ge(t){return t instanceof Date||"object"===typeof t&&"[object Date]"===Object.prototype.toString.call(t)}function Ze(t){return!(!Ge(t)&&"number"!==typeof t||isNaN(+h(t)))}function Qe(t,...e){const n=u.bind(null,t||e.find((t=>"object"===typeof t)));return e.map(n)}function Je(t,e){const n=h(t,e?.in);return n.setHours(0,0,0,0),n}function Ke(t,e,n){const[i,r]=Qe(n?.in,t,e),s=Je(i),a=Je(r),l=+s-ge(s),c=+a-ge(a);return Math.round((l-c)/o)}function tn(t,e){const n=h(t,e?.in);return n.setFullYear(n.getFullYear(),0,1),n.setHours(0,0,0,0),n}function en(t,e){const n=h(t,e?.in),i=Ke(n,tn(n)),r=i+1;return r}function nn(t,e){const n=t<0?"-":"",i=Math.abs(t).toString().padStart(e,"0");return n+i}const rn={y(t,e){const n=t.getFullYear(),i=n>0?n:1-n;return nn("yy"===e?i%100:i,e.length)},M(t,e){const n=t.getMonth();return"M"===e?String(n+1):nn(n+1,2)},d(t,e){return nn(t.getDate(),e.length)},a(t,e){const n=t.getHours()/12>=1?"pm":"am";switch(e){case"a":case"aa":return n.toUpperCase();case"aaa":return n;case"aaaaa":return n[0];case"aaaa":default:return"am"===n?"a.m.":"p.m."}},h(t,e){return nn(t.getHours()%12||12,e.length)},H(t,e){return nn(t.getHours(),e.length)},m(t,e){return nn(t.getMinutes(),e.length)},s(t,e){return nn(t.getSeconds(),e.length)},S(t,e){const n=e.length,i=t.getMilliseconds(),r=Math.trunc(i*Math.pow(10,n-3));return nn(r,e.length)}},on={am:"am",pm:"pm",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},sn={G:function(t,e,n){const i=t.getFullYear()>0?1:0;switch(e){case"G":case"GG":case"GGG":return n.era(i,{width:"abbreviated"});case"GGGGG":return n.era(i,{width:"narrow"});case"GGGG":default:return n.era(i,{width:"wide"})}},y:function(t,e,n){if("yo"===e){const e=t.getFullYear(),i=e>0?e:1-e;return n.ordinalNumber(i,{unit:"year"})}return rn.y(t,e)},Y:function(t,e,n,i){const r=At(t,i),o=r>0?r:1-r;if("YY"===e){const t=o%100;return nn(t,2)}return"Yo"===e?n.ordinalNumber(o,{unit:"year"}):nn(o,e.length)},R:function(t,e){const n=$t(t);return nn(n,e.length)},u:function(t,e){const n=t.getFullYear();return nn(n,e.length)},Q:function(t,e,n){const i=Math.ceil((t.getMonth()+1)/3);switch(e){case"Q":return String(i);case"QQ":return nn(i,2);case"Qo":return n.ordinalNumber(i,{unit:"quarter"});case"QQQ":return n.quarter(i,{width:"abbreviated",context:"formatting"});case"QQQQQ":return n.quarter(i,{width:"narrow",context:"formatting"});case"QQQQ":default:return n.quarter(i,{width:"wide",context:"formatting"})}},q:function(t,e,n){const i=Math.ceil((t.getMonth()+1)/3);switch(e){case"q":return String(i);case"qq":return nn(i,2);case"qo":return n.ordinalNumber(i,{unit:"quarter"});case"qqq":return n.quarter(i,{width:"abbreviated",context:"standalone"});case"qqqqq":return n.quarter(i,{width:"narrow",context:"standalone"});case"qqqq":default:return n.quarter(i,{width:"wide",context:"standalone"})}},M:function(t,e,n){const i=t.getMonth();switch(e){case"M":case"MM":return rn.M(t,e);case"Mo":return n.ordinalNumber(i+1,{unit:"month"});case"MMM":return n.month(i,{width:"abbreviated",context:"formatting"});case"MMMMM":return n.month(i,{width:"narrow",context:"formatting"});case"MMMM":default:return n.month(i,{width:"wide",context:"formatting"})}},L:function(t,e,n){const i=t.getMonth();switch(e){case"L":return String(i+1);case"LL":return nn(i+1,2);case"Lo":return n.ordinalNumber(i+1,{unit:"month"});case"LLL":return n.month(i,{width:"abbreviated",context:"standalone"});case"LLLLL":return n.month(i,{width:"narrow",context:"standalone"});case"LLLL":default:return n.month(i,{width:"wide",context:"standalone"})}},w:function(t,e,n,i){const r=jt(t,i);return"wo"===e?n.ordinalNumber(r,{unit:"week"}):nn(r,e.length)},I:function(t,e,n){const i=Yt(t);return"Io"===e?n.ordinalNumber(i,{unit:"week"}):nn(i,e.length)},d:function(t,e,n){return"do"===e?n.ordinalNumber(t.getDate(),{unit:"date"}):rn.d(t,e)},D:function(t,e,n){const i=en(t);return"Do"===e?n.ordinalNumber(i,{unit:"dayOfYear"}):nn(i,e.length)},E:function(t,e,n){const i=t.getDay();switch(e){case"E":case"EE":case"EEE":return n.day(i,{width:"abbreviated",context:"formatting"});case"EEEEE":return n.day(i,{width:"narrow",context:"formatting"});case"EEEEEE":return n.day(i,{width:"short",context:"formatting"});case"EEEE":default:return n.day(i,{width:"wide",context:"formatting"})}},e:function(t,e,n,i){const r=t.getDay(),o=(r-i.weekStartsOn+8)%7||7;switch(e){case"e":return String(o);case"ee":return nn(o,2);case"eo":return n.ordinalNumber(o,{unit:"day"});case"eee":return n.day(r,{width:"abbreviated",context:"formatting"});case"eeeee":return n.day(r,{width:"narrow",context:"formatting"});case"eeeeee":return n.day(r,{width:"short",context:"formatting"});case"eeee":default:return n.day(r,{width:"wide",context:"formatting"})}},c:function(t,e,n,i){const r=t.getDay(),o=(r-i.weekStartsOn+8)%7||7;switch(e){case"c":return String(o);case"cc":return nn(o,e.length);case"co":return n.ordinalNumber(o,{unit:"day"});case"ccc":return n.day(r,{width:"abbreviated",context:"standalone"});case"ccccc":return n.day(r,{width:"narrow",context:"standalone"});case"cccccc":return n.day(r,{width:"short",context:"standalone"});case"cccc":default:return n.day(r,{width:"wide",context:"standalone"})}},i:function(t,e,n){const i=t.getDay(),r=0===i?7:i;switch(e){case"i":return String(r);case"ii":return nn(r,e.length);case"io":return n.ordinalNumber(r,{unit:"day"});case"iii":return n.day(i,{width:"abbreviated",context:"formatting"});case"iiiii":return n.day(i,{width:"narrow",context:"formatting"});case"iiiiii":return n.day(i,{width:"short",context:"formatting"});case"iiii":default:return n.day(i,{width:"wide",context:"formatting"})}},a:function(t,e,n){const i=t.getHours(),r=i/12>=1?"pm":"am";switch(e){case"a":case"aa":return n.dayPeriod(r,{width:"abbreviated",context:"formatting"});case"aaa":return n.dayPeriod(r,{width:"abbreviated",context:"formatting"}).toLowerCase();case"aaaaa":return n.dayPeriod(r,{width:"narrow",context:"formatting"});case"aaaa":default:return n.dayPeriod(r,{width:"wide",context:"formatting"})}},b:function(t,e,n){const i=t.getHours();let r;switch(r=12===i?on.noon:0===i?on.midnight:i/12>=1?"pm":"am",e){case"b":case"bb":return n.dayPeriod(r,{width:"abbreviated",context:"formatting"});case"bbb":return n.dayPeriod(r,{width:"abbreviated",context:"formatting"}).toLowerCase();case"bbbbb":return n.dayPeriod(r,{width:"narrow",context:"formatting"});case"bbbb":default:return n.dayPeriod(r,{width:"wide",context:"formatting"})}},B:function(t,e,n){const i=t.getHours();let r;switch(r=i>=17?on.evening:i>=12?on.afternoon:i>=4?on.morning:on.night,e){case"B":case"BB":case"BBB":return n.dayPeriod(r,{width:"abbreviated",context:"formatting"});case"BBBBB":return n.dayPeriod(r,{width:"narrow",context:"formatting"});case"BBBB":default:return n.dayPeriod(r,{width:"wide",context:"formatting"})}},h:function(t,e,n){if("ho"===e){let e=t.getHours()%12;return 0===e&&(e=12),n.ordinalNumber(e,{unit:"hour"})}return rn.h(t,e)},H:function(t,e,n){return"Ho"===e?n.ordinalNumber(t.getHours(),{unit:"hour"}):rn.H(t,e)},K:function(t,e,n){const i=t.getHours()%12;return"Ko"===e?n.ordinalNumber(i,{unit:"hour"}):nn(i,e.length)},k:function(t,e,n){let i=t.getHours();return 0===i&&(i=24),"ko"===e?n.ordinalNumber(i,{unit:"hour"}):nn(i,e.length)},m:function(t,e,n){return"mo"===e?n.ordinalNumber(t.getMinutes(),{unit:"minute"}):rn.m(t,e)},s:function(t,e,n){return"so"===e?n.ordinalNumber(t.getSeconds(),{unit:"second"}):rn.s(t,e)},S:function(t,e){return rn.S(t,e)},X:function(t,e,n){const i=t.getTimezoneOffset();if(0===i)return"Z";switch(e){case"X":return ln(i);case"XXXX":case"XX":return cn(i);case"XXXXX":case"XXX":default:return cn(i,":")}},x:function(t,e,n){const i=t.getTimezoneOffset();switch(e){case"x":return ln(i);case"xxxx":case"xx":return cn(i);case"xxxxx":case"xxx":default:return cn(i,":")}},O:function(t,e,n){const i=t.getTimezoneOffset();switch(e){case"O":case"OO":case"OOO":return"GMT"+an(i,":");case"OOOO":default:return"GMT"+cn(i,":")}},z:function(t,e,n){const i=t.getTimezoneOffset();switch(e){case"z":case"zz":case"zzz":return"GMT"+an(i,":");case"zzzz":default:return"GMT"+cn(i,":")}},t:function(t,e,n){const i=Math.trunc(+t/1e3);return nn(i,e.length)},T:function(t,e,n){return nn(+t,e.length)}};function an(t,e=""){const n=t>0?"-":"+",i=Math.abs(t),r=Math.trunc(i/60),o=i%60;return 0===o?n+String(r):n+String(r)+e+nn(o,2)}function ln(t,e){if(t%60===0){const e=t>0?"-":"+";return e+nn(Math.abs(t)/60,2)}return cn(t,e)}function cn(t,e=""){const n=t>0?"-":"+",i=Math.abs(t),r=nn(Math.trunc(i/60),2),o=nn(i%60,2);return n+r+e+o}const un=/[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g,hn=/P+p+|P+|p+|''|'(''|[^'])+('|$)|./g,dn=/^'([^]*?)'?$/,fn=/''/g,pn=/[a-zA-Z]/;function gn(t,e,n){const i=st(),r=n?.locale??i.locale??q,o=n?.firstWeekContainsDate??n?.locale?.options?.firstWeekContainsDate??i.firstWeekContainsDate??i.locale?.options?.firstWeekContainsDate??1,s=n?.weekStartsOn??n?.locale?.options?.weekStartsOn??i.weekStartsOn??i.locale?.options?.weekStartsOn??0,a=h(t,n?.in);if(!Ze(a))throw new RangeError("Invalid time value");let l=e.match(hn).map((t=>{const e=t[0];if("p"===e||"P"===e){const n=Q[e];return n(t,r.formatLong)}return t})).join("").match(un).map((t=>{if("''"===t)return{isToken:!1,value:"'"};const e=t[0];if("'"===e)return{isToken:!1,value:mn(t)};if(sn[e])return{isToken:!0,value:t};if(e.match(pn))throw new RangeError("Format string contains an unescaped latin alphabet character `"+e+"`");return{isToken:!1,value:t}}));r.localize.preprocessor&&(l=r.localize.preprocessor(a,l));const c={firstWeekContainsDate:o,weekStartsOn:s,locale:r};return l.map((i=>{if(!i.isToken)return i.value;const o=i.value;(!n?.useAdditionalWeekYearTokens&&nt(o)||!n?.useAdditionalDayOfYearTokens&&et(o))&&it(o,e,String(t));const s=sn[o[0]];return s(a,o,r.localize,c)})).join("")}function mn(t){const e=t.match(dn);return e?e[1].replace(fn,"'"):t}function bn(t,e,n){return u(n?.in||t,+h(t)+e)}function xn(t,e,n){return bn(t,1e3*e,n)}function yn(t,e,n){const i=h(t,n?.in);return i.setTime(i.getTime()+e*s),i}function vn(t,e,n){return bn(t,e*a,n)}function wn(t,e,n){return Qt(t,7*e,n)}function kn(t,e,n){const i=h(t,n?.in);if(isNaN(e))return u(n?.in||t,NaN);if(!e)return i;const r=i.getDate(),o=u(n?.in||t,i.getTime());o.setMonth(i.getMonth()+e+1,0);const s=o.getDate();return r>=s?o:(i.setFullYear(o.getFullYear(),o.getMonth(),r),i)}function _n(t,e,n){return kn(t,3*e,n)}function Mn(t,e,n){return kn(t,12*e,n)}function Sn(t,e){return+h(t)-+h(e)}function Tn(t){return e=>{const n=t?Math[t]:Math.trunc,i=n(e);return 0===i?0:i}}function Dn(t,e,n){const i=Sn(t,e)/1e3;return Tn(n?.roundingMethod)(i)}function Cn(t,e,n){const i=Sn(t,e)/s;return Tn(n?.roundingMethod)(i)}function An(t,e,n){const[i,r]=Qe(n?.in,t,e),o=(+i-+r)/a;return Tn(n?.roundingMethod)(o)}function On(t,e,n){const[i,r]=Qe(n?.in,t,e),o=Pn(i,r),s=Math.abs(Ke(i,r));i.setDate(i.getDate()-o*s);const a=Number(Pn(i,r)===-o),l=o*(s-a);return 0===l?0:l}function Pn(t,e){const n=t.getFullYear()-e.getFullYear()||t.getMonth()-e.getMonth()||t.getDate()-e.getDate()||t.getHours()-e.getHours()||t.getMinutes()-e.getMinutes()||t.getSeconds()-e.getSeconds()||t.getMilliseconds()-e.getMilliseconds();return n<0?-1:n>0?1:n}function En(t,e,n){const i=On(t,e,n)/7;return Tn(n?.roundingMethod)(i)}function Rn(t,e){const n=+h(t)-+h(e);return n<0?-1:n>0?1:n}function In(t,e,n){const[i,r]=Qe(n?.in,t,e),o=i.getFullYear()-r.getFullYear(),s=i.getMonth()-r.getMonth();return 12*o+s}function Ln(t,e){const n=h(t,e?.in);return n.setHours(23,59,59,999),n}function zn(t,e){const n=h(t,e?.in),i=n.getMonth();return n.setFullYear(n.getFullYear(),i+1,0),n.setHours(23,59,59,999),n}function Nn(t,e){const n=h(t,e?.in);return+Ln(n,e)===+zn(n,e)}function Fn(t,e,n){const[i,r,o]=Qe(n?.in,t,t,e),s=Rn(r,o),a=Math.abs(In(r,o));if(a<1)return 0;1===r.getMonth()&&r.getDate()>27&&r.setDate(30),r.setMonth(r.getMonth()-s*a);let l=Rn(r,o)===-s;Nn(i)&&1===a&&1===Rn(i,o)&&(l=!1);const c=s*(a-+l);return 0===c?0:c}function jn(t,e,n){const i=Fn(t,e,n)/3;return Tn(n?.roundingMethod)(i)}function Hn(t,e,n){const[i,r]=Qe(n?.in,t,e);return i.getFullYear()-r.getFullYear()}function Wn(t,e,n){const[i,r]=Qe(n?.in,t,e),o=Rn(i,r),s=Math.abs(Hn(i,r));i.setFullYear(1584),r.setFullYear(1584);const a=Rn(i,r)===-o,l=o*(s-+a);return 0===l?0:l}function $n(t,e){const n=h(t,e?.in);return n.setMilliseconds(0),n}function Bn(t,e){const n=h(t,e?.in);return n.setSeconds(0,0),n}function Yn(t,e){const n=h(t,e?.in);return n.setMinutes(0,0,0),n}function Vn(t,e){const n=h(t,e?.in);return n.setDate(1),n.setHours(0,0,0,0),n}function Un(t,e){const n=h(t,e?.in),i=n.getMonth(),r=i-i%3;return n.setMonth(r,1),n.setHours(0,0,0,0),n}function qn(t,e){const n=h(t,e?.in);return n.setMilliseconds(999),n}function Xn(t,e){const n=h(t,e?.in);return n.setSeconds(59,999),n}function Gn(t,e){const n=h(t,e?.in);return n.setMinutes(59,59,999),n}function Zn(t,e){const n=st(),i=e?.weekStartsOn??e?.locale?.options?.weekStartsOn??n.weekStartsOn??n.locale?.options?.weekStartsOn??0,r=h(t,e?.in),o=r.getDay(),s=6+(on.intersect?t.inRange(e.x,e.y):a(t,e,n.axis)))}function u(t,e,n){let i=Number.POSITIVE_INFINITY;return c(t,e,n).reduce(((t,o)=>{const s=o.getCenterPoint(),a=l(e,s,n.axis),c=(0,r.aF)(e,a);return ct._index-e._index)).slice(0,1)}function h(t,e,n){const i=Math.cos(n),r=Math.sin(n),o=e.x,s=e.y;return{x:o+i*(t.x-o)-r*(t.y-s),y:s+r*(t.x-o)+i*(t.y-s)}}const d=(t,e)=>e>t||t.length>e.length&&t.slice(0,e.length)===e,f=.001,p=(t,e,n)=>Math.min(n,Math.max(e,t)),g=(t,e)=>t.value>=t.start-e&&t.value<=t.end+e;function m(t,e,n){for(const i of Object.keys(t))t[i]=p(t[i],e,n);return t}function b(t,e,n,i){return!(!t||!e||n<=0)&&Math.pow(t.x-e.x,2)+Math.pow(t.y-e.y,2)<=Math.pow(n+i,2)}function x(t,{x:e,y:n,x2:i,y2:r},o,{borderWidth:s,hitTolerance:a}){const l=(s+a)/2,c=t.x>=e-l-f&&t.x<=i+l+f,u=t.y>=n-l-f&&t.y<=r+l+f;return"x"===o?c:("y"===o||c)&&u}function y(t,{rect:e,center:n},i,{rotation:o,borderWidth:s,hitTolerance:a}){const l=h(t,n,(0,r.t)(-o));return x(l,e,i,{borderWidth:s,hitTolerance:a})}function v(t,e){const{centerX:n,centerY:i}=t.getProps(["centerX","centerY"],e);return{x:n,y:i}}function w(t,e,n,i=!0){const r=n.split(".");let o=0;for(const s of e.split(".")){const a=r[o++];if(parseInt(s,10)"string"===typeof t&&t.endsWith("%"),_=t=>parseFloat(t)/100,M=t=>p(_(t),0,1),S=(t,e)=>({x:t,y:e,x2:t,y2:e,width:0,height:0}),T={box:t=>S(t.centerX,t.centerY),doughnutLabel:t=>S(t.centerX,t.centerY),ellipse:t=>({centerX:t.centerX,centerY:t.centerX,radius:0,width:0,height:0}),label:t=>S(t.centerX,t.centerY),line:t=>S(t.x,t.y),point:t=>({centerX:t.centerX,centerY:t.centerY,radius:0,width:0,height:0}),polygon:t=>S(t.centerX,t.centerY)};function D(t,e){return"start"===e?0:"end"===e?t:k(e)?M(e)*t:t/2}function C(t,e,n=!0){return"number"===typeof e?e:k(e)?(n?M(e):_(e))*t:t}function A(t,e){const{x:n,width:i}=t,r=e.textAlign;return"center"===r?n+i/2:"end"===r||"right"===r?n+i:n}function O(t,e,{borderWidth:n,position:i,xAdjust:o,yAdjust:s},a){const l=(0,r.i)(a),c=e.width+(l?a.width:0)+n,u=e.height+(l?a.height:0)+n,h=P(i),d=L(t.x,c,o,h.x),f=L(t.y,u,s,h.y);return{x:d,y:f,x2:d+c,y2:f+u,width:c,height:u,centerX:d+c/2,centerY:f+u/2}}function P(t,e="center"){return(0,r.i)(t)?{x:(0,r.v)(t.x,e),y:(0,r.v)(t.y,e)}:(t=(0,r.v)(t,e),{x:t,y:t})}const E=(t,e)=>t&&t.autoFit&&e<1;function R(t,e){const n=t.font,i=(0,r.b)(n)?n:[n];return E(t,e)?i.map((function(t){const n=(0,r.a0)(t);return n.size=Math.floor(t.size*e),n.lineHeight=t.lineHeight,(0,r.a0)(n)})):i.map((t=>(0,r.a0)(t)))}function I(t){return t&&((0,r.h)(t.xValue)||(0,r.h)(t.yValue))}function L(t,e,n=0,i){return t-D(e,i)+n}function z(t,e,n){const i=n.init;if(i)return!0===i?F(e,n):j(t,e,n)}function N(t,e,n){let i=!1;return e.forEach((e=>{(0,r.a7)(t[e])?(i=!0,n[e]=t[e]):(0,r.h)(n[e])&&delete n[e]})),i}function F(t,e){const n=e.type||"line";return T[n](t)}function j(t,e,n){const i=(0,r.Q)(n.init,[{chart:t,properties:e,options:n}]);return!0===i?F(e,n):(0,r.i)(i)?i:void 0}const H=new Map,W=t=>isNaN(t)||t<=0,$=t=>t.reduce((function(t,e){return t+=e.string,t}),"");function B(t){if(t&&"object"===typeof t){const e=t.toString();return"[object HTMLImageElement]"===e||"[object HTMLCanvasElement]"===e}}function Y(t,{x:e,y:n},i){i&&(t.translate(e,n),t.rotate((0,r.t)(i)),t.translate(-e,-n))}function V(t,e){if(e&&e.borderWidth)return t.lineCap=e.borderCapStyle||"butt",t.setLineDash(e.borderDash),t.lineDashOffset=e.borderDashOffset,t.lineJoin=e.borderJoinStyle||"miter",t.lineWidth=e.borderWidth,t.strokeStyle=e.borderColor,!0}function U(t,e){t.shadowColor=e.backgroundShadowColor,t.shadowBlur=e.shadowBlur,t.shadowOffsetX=e.shadowOffsetX,t.shadowOffsetY=e.shadowOffsetY}function q(t,e){const n=e.content;if(B(n)){const t={width:C(n.width,e.width),height:C(n.height,e.height)};return t}const i=R(e),o=e.textStrokeWidth,s=(0,r.b)(n)?n:[n],a=s.join()+$(i)+o+(t._measureText?"-spriting":"");return H.has(a)||H.set(a,K(t,s,i,o)),H.get(a)}function X(t,e,n){const{x:i,y:o,width:s,height:a}=e;t.save(),U(t,n);const l=V(t,n);t.fillStyle=n.backgroundColor,t.beginPath(),(0,r.aw)(t,{x:i,y:o,w:s,h:a,radius:m((0,r.ay)(n.borderRadius),0,Math.min(s,a)/2)}),t.closePath(),t.fill(),l&&(t.shadowColor=n.borderShadowColor,t.stroke()),t.restore()}function G(t,e,n,i){const o=n.content;if(B(o))return t.save(),t.globalAlpha=nt(n.opacity,o.style.opacity),t.drawImage(o,e.x,e.y,e.width,e.height),void t.restore();const s=(0,r.b)(o)?o:[o],a=R(n,i),l=n.color,c=(0,r.b)(l)?l:[l],u=A(e,n),h=e.y+n.textStrokeWidth/2;t.save(),t.textBaseline="middle",t.textAlign=n.textAlign,Z(t,n)&&tt(t,{x:u,y:h},s,a),et(t,{x:u,y:h},s,{fonts:a,colors:c}),t.restore()}function Z(t,e){if(e.textStrokeWidth>0)return t.lineJoin="round",t.miterLimit=2,t.lineWidth=e.textStrokeWidth,t.strokeStyle=e.textStrokeColor,!0}function Q(t,e,n,i){const{radius:o,options:s}=e,a=s.pointStyle,l=s.rotation;let c=(l||0)*r.b4;if(B(a))return t.save(),t.translate(n,i),t.rotate(c),t.drawImage(a,-a.width/2,-a.height/2,a.width,a.height),void t.restore();W(o)||J(t,{x:n,y:i,radius:o,rotation:l,style:a,rad:c})}function J(t,{x:e,y:n,radius:i,rotation:o,style:s,rad:a}){let l,c,u,h;switch(t.beginPath(),s){default:t.arc(e,n,i,0,r.T),t.closePath();break;case"triangle":t.moveTo(e+Math.sin(a)*i,n-Math.cos(a)*i),a+=r.b6,t.lineTo(e+Math.sin(a)*i,n-Math.cos(a)*i),a+=r.b6,t.lineTo(e+Math.sin(a)*i,n-Math.cos(a)*i),t.closePath();break;case"rectRounded":h=.516*i,u=i-h,l=Math.cos(a+r.b5)*u,c=Math.sin(a+r.b5)*u,t.arc(e-l,n-c,h,a-r.P,a-r.H),t.arc(e+c,n-l,h,a-r.H,a),t.arc(e+l,n+c,h,a,a+r.H),t.arc(e-c,n+l,h,a+r.H,a+r.P),t.closePath();break;case"rect":if(!o){u=Math.SQRT1_2*i,t.rect(e-u,n-u,2*u,2*u);break}a+=r.b5;case"rectRot":l=Math.cos(a)*i,c=Math.sin(a)*i,t.moveTo(e-l,n-c),t.lineTo(e+c,n-l),t.lineTo(e+l,n+c),t.lineTo(e-c,n+l),t.closePath();break;case"crossRot":a+=r.b5;case"cross":l=Math.cos(a)*i,c=Math.sin(a)*i,t.moveTo(e-l,n-c),t.lineTo(e+l,n+c),t.moveTo(e+c,n-l),t.lineTo(e-c,n+l);break;case"star":l=Math.cos(a)*i,c=Math.sin(a)*i,t.moveTo(e-l,n-c),t.lineTo(e+l,n+c),t.moveTo(e+c,n-l),t.lineTo(e-c,n+l),a+=r.b5,l=Math.cos(a)*i,c=Math.sin(a)*i,t.moveTo(e-l,n-c),t.lineTo(e+l,n+c),t.moveTo(e+c,n-l),t.lineTo(e-c,n+l);break;case"line":l=Math.cos(a)*i,c=Math.sin(a)*i,t.moveTo(e-l,n-c),t.lineTo(e+l,n+c);break;case"dash":t.moveTo(e,n),t.lineTo(e+Math.cos(a)*i,n+Math.sin(a)*i);break}t.fill()}function K(t,e,n,i){t.save();const r=e.length;let o=0,s=i;for(let a=0;a0||0===o.borderWidth)&&(t.moveTo(c.x,c.y),t.lineTo(u.x,u.y)),t.moveTo(d.x,d.y),t.lineTo(f.x,f.y);const p=h({x:n,y:i},e.getCenterPoint(),(0,r.t)(-e.rotation));t.lineTo(p.x,p.y),t.stroke(),t.restore()}function ot(t,e){const{x:n,y:i,x2:r,y2:o}=t,s=st(t,e);let a,l;return"left"===e||"right"===e?(a={x:n+s,y:i},l={x:a.x,y:o}):(a={x:n,y:i+s},l={x:r,y:a.y}),{separatorStart:a,separatorEnd:l}}function st(t,e){const{width:n,height:i,options:r}=t,o=r.callout.margin+r.borderWidth/2;return"right"===e?n+o:"bottom"===e?i+o:-o}function at(t,e,n){const{y:i,width:r,height:o,options:s}=t,a=s.callout.start,l=lt(e,s.callout);let c,u;return"left"===e||"right"===e?(c={x:n.x,y:i+C(o,a)},u={x:c.x+l,y:c.y}):(c={x:n.x+C(r,a),y:n.y},u={x:c.x,y:c.y+l}),{sideStart:c,sideEnd:u}}function lt(t,e){const n=e.side;return"left"===t||"top"===t?-n:n}function ct(t,e){const n=e.position;return it.includes(n)?n:ut(t,e)}function ut(t,e){const{x:n,y:i,x2:o,y2:s,width:a,height:l,pointX:c,pointY:u,centerX:d,centerY:f,rotation:p}=t,g={x:d,y:f},m=e.start,b=C(a,m),x=C(l,m),y=[n,n+b,n+b,o],v=[i+x,s,i,s],w=[];for(let k=0;k<4;k++){const t=h({x:y[k],y:v[k]},g,(0,r.t)(p));w.push({position:it[k],distance:(0,r.aF)(t,{x:c,y:u})})}return w.sort(((t,e)=>t.distance-e.distance))[0].position}function ht(t,e,n){const{pointX:i,pointY:r}=t,o=e.margin;let s=i,a=r;return"left"===n?s+=o:"right"===n?s-=o:"top"===n?a+=o:"bottom"===n&&(a-=o),t.inRange(s,a)}const dt={xScaleID:{min:"xMin",max:"xMax",start:"left",end:"right",startProp:"x",endProp:"x2"},yScaleID:{min:"yMin",max:"yMax",start:"bottom",end:"top",startProp:"y",endProp:"y2"}};function ft(t,e,n){return e="number"===typeof e?e:t.parse(e),(0,r.g)(e)?t.getPixelForValue(e):n}function pt(t,e,n){const i=e[n];if(i||"scaleID"===n)return i;const r=n.charAt(0),o=Object.values(t).filter((t=>t.axis&&t.axis===r));return o.length?o[0].id:r}function gt(t,e){if(t){const n=t.options.reverse,i=ft(t,e.min,n?e.end:e.start),r=ft(t,e.max,n?e.start:e.end);return{start:i,end:r}}}function mt(t,e){const{chartArea:n,scales:i}=t,r=i[pt(i,e,"xScaleID")],o=i[pt(i,e,"yScaleID")];let s=n.width/2,a=n.height/2;return r&&(s=ft(r,e.xValue,r.left+r.width/2)),o&&(a=ft(o,e.yValue,o.top+o.height/2)),{x:s,y:a}}function bt(t,e){const n=t.scales,i=n[pt(n,e,"xScaleID")],r=n[pt(n,e,"yScaleID")];if(!i&&!r)return{};let{left:o,right:s}=i||t.chartArea,{top:a,bottom:l}=r||t.chartArea;const c=kt(i,{min:e.xMin,max:e.xMax,start:o,end:s});o=c.start,s=c.end;const u=kt(r,{min:e.yMin,max:e.yMax,start:l,end:a});return a=u.start,l=u.end,{x:o,y:a,x2:s,y2:l,width:s-o,height:l-a,centerX:o+(s-o)/2,centerY:a+(l-a)/2}}function xt(t,e){if(!I(e)){const n=bt(t,e);let i=e.radius;i&&!isNaN(i)||(i=Math.min(n.width,n.height)/2,e.radius=i);const r=2*i,o=n.centerX+e.xAdjust,s=n.centerY+e.yAdjust;return{x:o-i,y:s-i,x2:o+i,y2:s+i,centerX:o,centerY:s,width:r,height:r,radius:i}}return wt(t,e)}function yt(t,e){const{scales:n,chartArea:i}=t,r=n[e.scaleID],o={x:i.left,y:i.top,x2:i.right,y2:i.bottom};return r?_t(r,o,e):Mt(n,o,e),o}function vt(t,e){const n=bt(t,e);return n.initProperties=z(t,n,e),n.elements=[{type:"label",optionScope:"label",properties:Ct(t,n,e),initProperties:n.initProperties}],n}function wt(t,e){const n=mt(t,e),i=2*e.radius;return{x:n.x-e.radius+e.xAdjust,y:n.y-e.radius+e.yAdjust,x2:n.x+e.radius+e.xAdjust,y2:n.y+e.radius+e.yAdjust,centerX:n.x+e.xAdjust,centerY:n.y+e.yAdjust,radius:e.radius,width:i,height:i}}function kt(t,e){const n=gt(t,e)||e;return{start:Math.min(n.start,n.end),end:Math.max(n.start,n.end)}}function _t(t,e,n){const i=ft(t,n.value,NaN),r=ft(t,n.endValue,i);t.isHorizontal()?(e.x=i,e.x2=r):(e.y=i,e.y2=r)}function Mt(t,e,n){for(const i of Object.keys(dt)){const r=t[pt(t,n,i)];if(r){const{min:t,max:o,start:s,end:a,startProp:l,endProp:c}=dt[i],u=gt(r,{min:n[t],max:n[o],start:r[s],end:r[a]});e[l]=u.start,e[c]=u.end}}}function St({properties:t,options:e},n,i,r){const{x:o,x2:s,width:a}=t;return Dt({start:o,end:s,size:a,borderWidth:e.borderWidth},{position:i.x,padding:{start:r.left,end:r.right},adjust:e.label.xAdjust,size:n.width})}function Tt({properties:t,options:e},n,i,r){const{y:o,y2:s,height:a}=t;return Dt({start:o,end:s,size:a,borderWidth:e.borderWidth},{position:i.y,padding:{start:r.top,end:r.bottom},adjust:e.label.yAdjust,size:n.height})}function Dt(t,e){const{start:n,end:i,borderWidth:r}=t,{position:o,padding:{start:s,end:a},adjust:l}=e,c=i-r-n-s-a-e.size;return n+r/2+l+D(c,o)}function Ct(t,e,n){const i=n.label;i.backgroundColor="transparent",i.callout.display=!1;const o=P(i.position),s=(0,r.E)(i.padding),a=q(t.ctx,i),l=St({properties:e,options:n},a,o,s),c=Tt({properties:e,options:n},a,o,s),u=a.width+s.width,h=a.height+s.height;return{x:l,y:c,x2:l+u,y2:c+h,width:u,height:h,centerX:l+u/2,centerY:c+h/2,rotation:i.rotation}}const At=["enter","leave"],Ot=At.concat("click");function Pt(t,e,n){e.listened=N(n,Ot,e.listeners),e.moveListened=!1,At.forEach((t=>{(0,r.a7)(n[t])&&(e.moveListened=!0)})),e.listened&&e.moveListened||e.annotations.forEach((t=>{!e.listened&&(0,r.a7)(t.click)&&(e.listened=!0),e.moveListened||At.forEach((n=>{(0,r.a7)(t[n])&&(e.listened=!0,e.moveListened=!0)}))}))}function Et(t,e,n){if(t.listened)switch(e.type){case"mousemove":case"mouseout":return Rt(t,e,n);case"click":return Lt(t,e,n)}}function Rt(t,e,n){if(!t.moveListened)return;let i;i="mousemove"===e.type?s(t.visibleElements,e,n.interaction):[];const r=t.hovered;t.hovered=i;const o={state:t,event:e};let a=It(o,"leave",r,i);return It(o,"enter",i,r)||a}function It({state:t,event:e},n,i,r){let o;for(const s of i)r.indexOf(s)<0&&(o=zt(s.options[n]||t.listeners[n],s,e)||o);return o}function Lt(t,e,n){const i=t.listeners,r=s(t.visibleElements,e,n.interaction);let o;for(const s of r)o=zt(s.options.click||i.click,s,e)||o;return o}function zt(t,e,n){return!0===(0,r.Q)(t,[e.$context,n])}const Nt=["afterDraw","beforeDraw"];function Ft(t,e,n){const i=e.visibleElements;e.hooked=N(n,Nt,e.hooks),e.hooked||i.forEach((t=>{e.hooked||Nt.forEach((n=>{(0,r.a7)(t.options[n])&&(e.hooked=!0)}))}))}function jt(t,e,n){if(t.hooked){const i=e.options[n]||t.hooks[n];return(0,r.Q)(i,[e.$context])}}function Ht(t,e,n){const i=Ut(t.scales,e,n);let o=$t(e,i,"min","suggestedMin");o=$t(e,i,"max","suggestedMax")||o,o&&(0,r.a7)(e.handleTickRangeOptions)&&e.handleTickRangeOptions()}function Wt(t,e){for(const n of t)Yt(n,e)}function $t(t,e,n,i){if((0,r.g)(e[n])&&!Bt(t.options,n,i)){const i=t[n]!==e[n];return t[n]=e[n],i}}function Bt(t,e,n){return(0,r.h)(t[e])||(0,r.h)(t[n])}function Yt(t,e){for(const n of["scaleID","xScaleID","yScaleID"]){const i=pt(e,t,n);i&&!e[i]&&Vt(t,n)&&console.warn(`No scale found with id '${i}' for annotation '${t.id}'`)}}function Vt(t,e){if("scaleID"===e)return!0;const n=e.charAt(0);for(const i of["Min","Max","Value"])if((0,r.h)(t[n+i]))return!0;return!1}function Ut(t,e,n){const i=e.axis,o=e.id,s=i+"ScaleID",a={min:(0,r.v)(e.min,Number.NEGATIVE_INFINITY),max:(0,r.v)(e.max,Number.POSITIVE_INFINITY)};for(const r of n)r.scaleID===o?qt(r,e,["value","endValue"],a):pt(t,r,s)===o&&qt(r,e,[i+"Min",i+"Max",i+"Value"],a);return a}function qt(t,e,n,i){for(const o of n){const n=t[o];if((0,r.h)(n)){const t=e.parse(n);i.min=Math.min(i.min,t),i.max=Math.max(i.max,t)}}}class Xt extends i.W_{inRange(t,e,n,i){const{x:o,y:s}=h({x:t,y:e},this.getCenterPoint(i),(0,r.t)(-this.options.rotation));return x({x:o,y:s},this.getProps(["x","y","x2","y2"],i),n,this.options)}getCenterPoint(t){return v(this,t)}draw(t){t.save(),Y(t,this.getCenterPoint(),this.options.rotation),X(t,this,this.options),t.restore()}get label(){return this.elements&&this.elements[0]}resolveElementProperties(t,e){return vt(t,e)}}Xt.id="boxAnnotation",Xt.defaults={adjustScaleRange:!0,backgroundShadowColor:"transparent",borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderRadius:0,borderShadowColor:"transparent",borderWidth:1,display:!0,init:void 0,hitTolerance:0,label:{backgroundColor:"transparent",borderWidth:0,callout:{display:!1},color:"black",content:null,display:!1,drawTime:void 0,font:{family:void 0,lineHeight:void 0,size:void 0,style:void 0,weight:"bold"},height:void 0,hitTolerance:void 0,opacity:void 0,padding:6,position:"center",rotation:void 0,textAlign:"start",textStrokeColor:void 0,textStrokeWidth:0,width:void 0,xAdjust:0,yAdjust:0,z:void 0},rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,xMax:void 0,xMin:void 0,xScaleID:void 0,yMax:void 0,yMin:void 0,yScaleID:void 0,z:0},Xt.defaultRoutes={borderColor:"color",backgroundColor:"color"},Xt.descriptors={label:{_fallback:!0}};class Gt extends i.W_{inRange(t,e,n,i){return y({x:t,y:e},{rect:this.getProps(["x","y","x2","y2"],i),center:this.getCenterPoint(i)},n,{rotation:this.rotation,borderWidth:0,hitTolerance:this.options.hitTolerance})}getCenterPoint(t){return v(this,t)}draw(t){const e=this.options;e.display&&e.content&&(ee(t,this),t.save(),Y(t,this.getCenterPoint(),this.rotation),G(t,this,e,this._fitRatio),t.restore())}resolveElementProperties(t,e){const n=Zt(t,e);if(!n)return{};const{controllerMeta:i,point:r,radius:o}=Jt(t,e,n);let s=q(t.ctx,e);const a=Kt(s,o);E(e,a)&&(s={width:s.width*a,height:s.height*a});const{position:l,xAdjust:c,yAdjust:u}=e,h=O(r,s,{borderWidth:0,position:l,xAdjust:c,yAdjust:u});return{initProperties:z(t,h,e),...h,...i,rotation:e.rotation,_fitRatio:a}}}function Zt(t,e){return t.getSortedVisibleDatasetMetas().reduce((function(n,r){const o=r.controller;return o instanceof i.jI&&Qt(t,e,r.data)&&(!n||o.innerRadius=90?r:n}),void 0)}function Qt(t,e,n){if(!e.autoHide)return!0;for(let i=0;ih,b=m?r+p:s-p,x=te(b,u,h,g),y={_centerX:u,_centerY:h,_radius:g,_counterclockwise:m,...x};return{controllerMeta:y,point:f,radius:Math.min(a,Math.min(d.right-d.left,d.bottom-d.top)/2)}}function Kt({width:t,height:e},n){const i=Math.sqrt(Math.pow(t,2)+Math.pow(e,2));return 2*n/i}function te(t,e,n,i){const o=Math.pow(n-t,2),s=Math.pow(i,2),a=-2*e,l=Math.pow(e,2)+o-s,c=Math.pow(a,2)-4*l;if(c<=0)return{_startAngle:0,_endAngle:r.T};const u=(-a-Math.sqrt(c))/2,h=(-a+Math.sqrt(c))/2;return{_startAngle:(0,r.D)({x:e,y:n},{x:u,y:t}).angle,_endAngle:(0,r.D)({x:e,y:n},{x:h,y:t}).angle}}function ee(t,e){const{_centerX:n,_centerY:i,_radius:r,_startAngle:o,_endAngle:s,_counterclockwise:a,options:l}=e;t.save();const c=V(t,l);t.fillStyle=l.backgroundColor,t.beginPath(),t.arc(n,i,r,o,s,a),t.closePath(),t.fill(),c&&t.stroke(),t.restore()}Gt.id="doughnutLabelAnnotation",Gt.defaults={autoFit:!0,autoHide:!0,backgroundColor:"transparent",backgroundShadowColor:"transparent",borderColor:"transparent",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderShadowColor:"transparent",borderWidth:0,color:"black",content:null,display:!0,font:{family:void 0,lineHeight:void 0,size:void 0,style:void 0,weight:void 0},height:void 0,hitTolerance:0,init:void 0,opacity:void 0,position:"center",rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,spacing:1,textAlign:"center",textStrokeColor:void 0,textStrokeWidth:0,width:void 0,xAdjust:0,yAdjust:0},Gt.defaultRoutes={};class ne extends i.W_{inRange(t,e,n,i){return y({x:t,y:e},{rect:this.getProps(["x","y","x2","y2"],i),center:this.getCenterPoint(i)},n,{rotation:this.rotation,borderWidth:this.options.borderWidth,hitTolerance:this.options.hitTolerance})}getCenterPoint(t){return v(this,t)}draw(t){const e=this.options,n=!(0,r.h)(this._visible)||this._visible;e.display&&e.content&&n&&(t.save(),Y(t,this.getCenterPoint(),this.rotation),rt(t,this),X(t,this,e),G(t,ie(this),e),t.restore())}resolveElementProperties(t,e){let n;if(I(e))n=mt(t,e);else{const{centerX:i,centerY:r}=bt(t,e);n={x:i,y:r}}const i=(0,r.E)(e.padding),o=q(t.ctx,e),s=O(n,o,e,i);return{initProperties:z(t,s,e),pointX:n.x,pointY:n.y,...s,rotation:e.rotation}}}function ie({x:t,y:e,width:n,height:i,options:o}){const s=o.borderWidth/2,a=(0,r.E)(o.padding);return{x:t+a.left+s,y:e+a.top+s,width:n-a.left-a.right-o.borderWidth,height:i-a.top-a.bottom-o.borderWidth}}ne.id="labelAnnotation",ne.defaults={adjustScaleRange:!0,backgroundColor:"transparent",backgroundShadowColor:"transparent",borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderRadius:0,borderShadowColor:"transparent",borderWidth:0,callout:{borderCapStyle:"butt",borderColor:void 0,borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:1,display:!1,margin:5,position:"auto",side:5,start:"50%"},color:"black",content:null,display:!0,font:{family:void 0,lineHeight:void 0,size:void 0,style:void 0,weight:void 0},height:void 0,hitTolerance:0,init:void 0,opacity:void 0,padding:6,position:"center",rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,textAlign:"center",textStrokeColor:void 0,textStrokeWidth:0,width:void 0,xAdjust:0,xMax:void 0,xMin:void 0,xScaleID:void 0,xValue:void 0,yAdjust:0,yMax:void 0,yMin:void 0,yScaleID:void 0,yValue:void 0,z:0},ne.defaultRoutes={borderColor:"color"};const re=(t,e,n)=>({x:t.x+n*(e.x-t.x),y:t.y+n*(e.y-t.y)}),oe=(t,e,n)=>re(e,n,Math.abs((t-e.y)/(n.y-e.y))).x,se=(t,e,n)=>re(e,n,Math.abs((t-e.x)/(n.x-e.x))).y,ae=t=>t*t,le=(t,e,{x:n,y:i,x2:r,y2:o},s)=>"y"===s?{start:Math.min(i,o),end:Math.max(i,o),value:e}:{start:Math.min(n,r),end:Math.max(n,r),value:t},ce=(t,e,n,i)=>(1-i)*(1-i)*t+2*(1-i)*i*e+i*i*n,ue=(t,e,n,i)=>({x:ce(t.x,e.x,n.x,i),y:ce(t.y,e.y,n.y,i)}),he=(t,e,n,i)=>2*(1-i)*(e-t)+2*i*(n-e),de=(t,e,n,i)=>-Math.atan2(he(t.x,e.x,n.x,i),he(t.y,e.y,n.y,i))+.5*r.P;class fe extends i.W_{inRange(t,e,n,i){const r=(this.options.borderWidth+this.options.hitTolerance)/2;if("x"!==n&&"y"!==n){const n={mouseX:t,mouseY:e},{path:o,ctx:s}=this;if(o){V(s,this.options),s.lineWidth+=this.options.hitTolerance;const{chart:r}=this.$context,a=t*r.currentDevicePixelRatio,l=e*r.currentDevicePixelRatio,c=s.isPointInStroke(o,a,l)||ve(this,n,i);return s.restore(),c}const a=ae(r);return ye(this,n,a,i)||ve(this,n,i)}return ge(this,{mouseX:t,mouseY:e},n,{hitSize:r,useFinalPosition:i})}getCenterPoint(t){return v(this,t)}draw(t){const{x:e,y:n,x2:i,y2:r,cp:o,options:s}=this;if(t.save(),!V(t,s))return t.restore();U(t,s);const a=Math.sqrt(Math.pow(i-e,2)+Math.pow(r-n,2));if(s.curve&&o)return Ie(t,this,o,a),t.restore();const{startOpts:l,endOpts:c,startAdjust:u,endAdjust:h}=Ae(this),d=Math.atan2(r-n,i-e);t.translate(e,n),t.rotate(d),t.beginPath(),t.moveTo(0+u,0),t.lineTo(a-h,0),t.shadowColor=s.borderShadowColor,t.stroke(),Pe(t,0,u,l),Pe(t,a,-h,c),t.restore()}get label(){return this.elements&&this.elements[0]}resolveElementProperties(t,e){const n=yt(t,e),{x:i,y:o,x2:s,y2:a}=n,l=me(n,t.chartArea),c=l?xe({x:i,y:o},{x:s,y:a},t.chartArea):{x:i,y:o,x2:s,y2:a,width:Math.abs(s-i),height:Math.abs(a-o)};if(c.centerX=(s+i)/2,c.centerY=(a+o)/2,c.initProperties=z(t,c,e),e.curve){const t={x:c.x,y:c.y},n={x:c.x2,y:c.y2};c.cp=Ee(c,e,(0,r.aF)(t,n))}const u=we(t,c,e.label);return u._visible=l,c.elements=[{type:"label",optionScope:"label",properties:u,initProperties:c.initProperties}],c}}fe.id="lineAnnotation";const pe={backgroundColor:void 0,backgroundShadowColor:void 0,borderColor:void 0,borderDash:void 0,borderDashOffset:void 0,borderShadowColor:void 0,borderWidth:void 0,display:void 0,fill:void 0,length:void 0,shadowBlur:void 0,shadowOffsetX:void 0,shadowOffsetY:void 0,width:void 0};function ge(t,{mouseX:e,mouseY:n},i,{hitSize:r,useFinalPosition:o}){const s=le(e,n,t.getProps(["x","y","x2","y2"],o),i);return g(s,r)||ve(t,{mouseX:e,mouseY:n},o,i)}function me({x:t,y:e,x2:n,y2:i},{top:r,right:o,bottom:s,left:a}){return!(to&&n>o||es&&i>s)}function be({x:t,y:e},n,{top:i,right:r,bottom:o,left:s}){return tr&&(e=se(r,{x:t,y:e},n),t=r),eo&&(t=oe(o,{x:t,y:e},n),e=o),{x:t,y:e}}function xe(t,e,n){const{x:i,y:r}=be(t,e,n),{x:o,y:s}=be(e,t,n);return{x:i,y:r,x2:o,y2:s,width:Math.abs(o-i),height:Math.abs(s-r)}}function ye(t,{mouseX:e,mouseY:n},i=f,r){const{x:o,y:s,x2:a,y2:l}=t.getProps(["x","y","x2","y2"],r),c=a-o,u=l-s,h=ae(c)+ae(u),d=0===h?-1:((e-o)*c+(n-s)*u)/h;let p,g;return d<0?(p=o,g=s):d>1?(p=a,g=l):(p=o+d*c,g=s+d*u),ae(e-p)+ae(n-g)<=i}function ve(t,{mouseX:e,mouseY:n},i,r){const o=t.label;return o.options.display&&o.inRange(e,n,r,i)}function we(t,e,n){const i=n.borderWidth,o=(0,r.E)(n.padding),s=q(t.ctx,n),a=s.width+o.width+i,l=s.height+o.height+i;return _e(e,n,{width:a,height:l,padding:o},t.chartArea)}function ke(t){const{x:e,y:n,x2:i,y2:o}=t,s=Math.atan2(o-n,i-e);return s>r.P/2?s-r.P:s0&&(r.w/2+o.left-i.x)/s,c=a>0&&(r.h/2+o.top-i.y)/a;return p(Math.max(l,c),0,.25)}function De(t,e){const{x:n,x2:i,y:r,y2:o}=t,s=Math.min(r,o)-e.top,a=Math.min(n,i)-e.left,l=e.bottom-Math.max(r,o),c=e.right-Math.max(n,i);return{x:Math.min(a,c),y:Math.min(s,l),dx:a<=c?1:-1,dy:s<=l?1:-1}}function Ce(t,e){const{size:n,min:i,max:r,padding:o}=e,s=n/2;return n>r-i?(r+i)/2:(i>=t-o-s&&(t=i+o+s),r<=t+o+s&&(t=r-o-s),t)}function Ae(t){const e=t.options,n=e.arrowHeads&&e.arrowHeads.start,i=e.arrowHeads&&e.arrowHeads.end;return{startOpts:n,endOpts:i,startAdjust:Oe(t,n),endAdjust:Oe(t,i)}}function Oe(t,e){if(!e||!e.display)return 0;const{length:n,width:i}=e,r=t.options.borderWidth/2,o={x:n,y:i+r},s={x:0,y:r};return Math.abs(oe(0,o,s))}function Pe(t,e,n,i){if(!i||!i.display)return;const{length:r,width:o,fill:s,backgroundColor:a,borderColor:l}=i,c=Math.abs(e-r)+n;t.beginPath(),U(t,i),V(t,i),t.moveTo(c,-o),t.lineTo(e+n,0),t.lineTo(c,o),!0===s?(t.fillStyle=a||l,t.closePath(),t.fill(),t.shadowColor="transparent"):t.shadowColor=i.borderShadowColor,t.stroke()}function Ee(t,e,n){const{x:i,y:r,x2:o,y2:s,centerX:a,centerY:l}=t,c=Math.atan2(s-r,o-i),u=P(e.controlPoint,0),d={x:a+C(n,u.x,!1),y:l+C(n,u.y,!1)};return h(d,{x:a,y:l},c)}function Re(t,{x:e,y:n},{angle:i,adjust:r},o){o&&o.display&&(t.save(),t.translate(e,n),t.rotate(i),Pe(t,0,-r,o),t.restore())}function Ie(t,e,n,i){const{x:o,y:s,x2:a,y2:l,options:c}=e,{startOpts:u,endOpts:h,startAdjust:d,endAdjust:f}=Ae(e),p={x:o,y:s},g={x:a,y:l},m=de(p,n,g,0),b=de(p,n,g,1)-r.P,x=ue(p,n,g,d/i),y=ue(p,n,g,1-f/i),v=new Path2D;t.beginPath(),v.moveTo(x.x,x.y),v.quadraticCurveTo(n.x,n.y,y.x,y.y),t.shadowColor=c.borderShadowColor,t.stroke(v),e.path=v,e.ctx=t,Re(t,x,{angle:m,adjust:d},u),Re(t,y,{angle:b,adjust:f},h)}fe.defaults={adjustScaleRange:!0,arrowHeads:{display:!1,end:Object.assign({},pe),fill:!1,length:12,start:Object.assign({},pe),width:6},borderDash:[],borderDashOffset:0,borderShadowColor:"transparent",borderWidth:2,curve:!1,controlPoint:{y:"-50%"},display:!0,endValue:void 0,init:void 0,hitTolerance:0,label:{backgroundColor:"rgba(0,0,0,0.8)",backgroundShadowColor:"transparent",borderCapStyle:"butt",borderColor:"black",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderRadius:6,borderShadowColor:"transparent",borderWidth:0,callout:Object.assign({},ne.defaults.callout),color:"#fff",content:null,display:!1,drawTime:void 0,font:{family:void 0,lineHeight:void 0,size:void 0,style:void 0,weight:"bold"},height:void 0,hitTolerance:void 0,opacity:void 0,padding:6,position:"center",rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,textAlign:"center",textStrokeColor:void 0,textStrokeWidth:0,width:void 0,xAdjust:0,yAdjust:0,z:void 0},scaleID:void 0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,value:void 0,xMax:void 0,xMin:void 0,xScaleID:void 0,yMax:void 0,yMin:void 0,yScaleID:void 0,z:0},fe.descriptors={arrowHeads:{start:{_fallback:!0},end:{_fallback:!0},_fallback:!0}},fe.defaultRoutes={borderColor:"color"};class Le extends i.W_{inRange(t,e,n,i){const o=this.options.rotation,s=(this.options.borderWidth+this.options.hitTolerance)/2;if("x"!==n&&"y"!==n)return ze({x:t,y:e},this.getProps(["width","height","centerX","centerY"],i),o,s);const{x:a,y:l,x2:c,y2:u}=this.getProps(["x","y","x2","y2"],i),d="y"===n?{start:l,end:u}:{start:a,end:c},p=h({x:t,y:e},this.getCenterPoint(i),(0,r.t)(-o));return p[n]>=d.start-s-f&&p[n]<=d.end+s+f}getCenterPoint(t){return v(this,t)}draw(t){const{width:e,height:n,centerX:i,centerY:o,options:s}=this;t.save(),Y(t,this.getCenterPoint(),s.rotation),U(t,this.options),t.beginPath(),t.fillStyle=s.backgroundColor;const a=V(t,s);t.ellipse(i,o,n/2,e/2,r.P/2,0,2*r.P),t.fill(),a&&(t.shadowColor=s.borderShadowColor,t.stroke()),t.restore()}get label(){return this.elements&&this.elements[0]}resolveElementProperties(t,e){return vt(t,e)}}function ze(t,e,n,i){const{width:o,height:s,centerX:a,centerY:l}=e,c=o/2,u=s/2;if(c<=0||u<=0)return!1;const h=(0,r.t)(n||0),d=Math.cos(h),f=Math.sin(h),p=Math.pow(d*(t.x-a)+f*(t.y-l),2),g=Math.pow(f*(t.x-a)-d*(t.y-l),2);return p/Math.pow(c+i,2)+g/Math.pow(u+i,2)<=1.0001}Le.id="ellipseAnnotation",Le.defaults={adjustScaleRange:!0,backgroundShadowColor:"transparent",borderDash:[],borderDashOffset:0,borderShadowColor:"transparent",borderWidth:1,display:!0,hitTolerance:0,init:void 0,label:Object.assign({},Xt.defaults.label),rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,xMax:void 0,xMin:void 0,xScaleID:void 0,yMax:void 0,yMin:void 0,yScaleID:void 0,z:0},Le.defaultRoutes={borderColor:"color",backgroundColor:"color"},Le.descriptors={label:{_fallback:!0}};class Ne extends i.W_{inRange(t,e,n,i){const{x:r,y:o,x2:s,y2:a,width:l}=this.getProps(["x","y","x2","y2","width"],i),c=(this.options.borderWidth+this.options.hitTolerance)/2;if("x"!==n&&"y"!==n)return b({x:t,y:e},this.getCenterPoint(i),l/2,c);const u="y"===n?{start:o,end:a,value:e}:{start:r,end:s,value:t};return g(u,c)}getCenterPoint(t){return v(this,t)}draw(t){const e=this.options,n=e.borderWidth;if(e.radius<.1)return;t.save(),t.fillStyle=e.backgroundColor,U(t,e);const i=V(t,e);Q(t,this,this.centerX,this.centerY),i&&!B(e.pointStyle)&&(t.shadowColor=e.borderShadowColor,t.stroke()),t.restore(),e.borderWidth=n}resolveElementProperties(t,e){const n=xt(t,e);return n.initProperties=z(t,n,e),n}}Ne.id="pointAnnotation",Ne.defaults={adjustScaleRange:!0,backgroundShadowColor:"transparent",borderDash:[],borderDashOffset:0,borderShadowColor:"transparent",borderWidth:1,display:!0,hitTolerance:0,init:void 0,pointStyle:"circle",radius:10,rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,xAdjust:0,xMax:void 0,xMin:void 0,xScaleID:void 0,xValue:void 0,yAdjust:0,yMax:void 0,yMin:void 0,yScaleID:void 0,yValue:void 0,z:0},Ne.defaultRoutes={borderColor:"color",backgroundColor:"color"};class Fe extends i.W_{inRange(t,e,n,i){if("x"!==n&&"y"!==n)return this.options.radius>=.1&&this.elements.length>1&&He(this.elements,t,e,i);const o=h({x:t,y:e},this.getCenterPoint(i),(0,r.t)(-this.options.rotation)),s=this.elements.map((t=>"y"===n?t.bY:t.bX)),a=Math.min(...s),l=Math.max(...s);return o[n]>=a&&o[n]<=l}getCenterPoint(t){return v(this,t)}draw(t){const{elements:e,options:n}=this;t.save(),t.beginPath(),t.fillStyle=n.backgroundColor,U(t,n);const i=V(t,n);let r=!0;for(const o of e)r?(t.moveTo(o.x,o.y),r=!1):t.lineTo(o.x,o.y);t.closePath(),t.fill(),i&&(t.shadowColor=n.borderShadowColor,t.stroke()),t.restore()}resolveElementProperties(t,e){const n=xt(t,e),{sides:i,rotation:o}=e,s=[],a=2*r.P/i;let l=o*r.b4;for(let r=0;rn!==o.bY>n&&e<(o.bX-t.bX)*(n-t.bY)/(o.bY-t.bY)+t.bX&&(r=!r),o=t}return r}Fe.id="polygonAnnotation",Fe.defaults={adjustScaleRange:!0,backgroundShadowColor:"transparent",borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderShadowColor:"transparent",borderWidth:1,display:!0,hitTolerance:0,init:void 0,point:{radius:0},radius:10,rotation:0,shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,sides:3,xAdjust:0,xMax:void 0,xMin:void 0,xScaleID:void 0,xValue:void 0,yAdjust:0,yMax:void 0,yMin:void 0,yScaleID:void 0,yValue:void 0,z:0},Fe.defaultRoutes={borderColor:"color",backgroundColor:"color"};const We={box:Xt,doughnutLabel:Gt,ellipse:Le,label:ne,line:fe,point:Ne,polygon:Fe};Object.keys(We).forEach((t=>{r.d.describe(`elements.${We[t].id}`,{_fallback:"plugins.annotation.common"})}));const $e={update:Object.assign},Be=Ot.concat(Nt),Ye=(t,e)=>(0,r.i)(e)?Ke(t,e):t,Ve=t=>"color"===t||"font"===t;function Ue(t="line"){return We[t]?t:(console.warn(`Unknown annotation type: '${t}', defaulting to 'line'`),"line")}function qe(t,e,n,i){const o=Ge(t,n.animations,i),s=e.annotations,a=en(e.elements,s);for(let l=0;lYe(t,o))):n[i]=Ye(s,o)}return n}function tn(t,e,n,i){return e.$context||(e.$context=Object.assign(Object.create(t.getContext()),{element:e,get elements(){return n.filter((t=>t&&t.options))},id:i.id,type:"annotation"}))}function en(t,e){const n=e.length,i=t.length;if(in&&t.splice(n,i-n);return t}var nn="3.1.0";const rn=new Map,on=t=>"doughnutLabel"!==t.type,sn=Ot.concat(Nt);var an={id:"annotation",version:nn,beforeRegister(){w("chart.js","4.0",i.kL.version)},afterRegister(){i.kL.register(We)},afterUnregister(){i.kL.unregister(We)},beforeInit(t){rn.set(t,{annotations:[],elements:[],visibleElements:[],listeners:{},listened:!1,moveListened:!1,hooks:{},hooked:!1,hovered:[]})},beforeUpdate(t,e,n){const i=rn.get(t),o=i.annotations=[];let s=n.annotations;(0,r.i)(s)?Object.keys(s).forEach((t=>{const e=s[t];(0,r.i)(e)&&(e.id=t,o.push(e))})):(0,r.b)(s)&&o.push(...s),Wt(o.filter(on),t.scales)},afterDataLimits(t,e){const n=rn.get(t);Ht(t,e.scale,n.annotations.filter(on).filter((t=>t.display&&t.adjustScaleRange)))},afterUpdate(t,e,n){const i=rn.get(t);Pt(t,i,n),qe(t,i,n,e.mode),i.visibleElements=i.elements.filter((t=>!t.skip&&t.options.display)),Ft(t,i,n)},beforeDatasetsDraw(t,e,n){ln(t,"beforeDatasetsDraw",n.clip)},afterDatasetsDraw(t,e,n){ln(t,"afterDatasetsDraw",n.clip)},beforeDatasetDraw(t,e,n){ln(t,e.index,n.clip)},beforeDraw(t,e,n){ln(t,"beforeDraw",n.clip)},afterDraw(t,e,n){ln(t,"afterDraw",n.clip)},beforeEvent(t,e,n){const i=rn.get(t);Et(i,e.event,n)&&(e.changed=!0)},afterDestroy(t){rn.delete(t)},getAnnotations(t){const e=rn.get(t);return e?e.elements:[]},_getAnnotationElementsAtEventForMode(t,e,n){return s(t,e,n)},defaults:{animations:{numbers:{properties:["x","y","x2","y2","width","height","centerX","centerY","pointX","pointY","radius"],type:"number"},colors:{properties:["backgroundColor","borderColor"],type:"color"}},clip:!0,interaction:{mode:void 0,axis:void 0,intersect:void 0},common:{drawTime:"afterDatasetsDraw",init:!1,label:{}}},descriptors:{_indexable:!1,_scriptable:t=>!sn.includes(t)&&"init"!==t,annotations:{_allKeys:!1,_fallback:(t,e)=>`elements.${We[Ue(e.type)].id}`},interaction:{_fallback:!0},common:{label:{_indexable:Ve,_fallback:!0},_indexable:Ve}},additionalOptionScopes:[""]};function ln(t,e,n){const{ctx:i,chartArea:o}=t,s=rn.get(t);n&&(0,r.Y)(i,o);const a=cn(s.visibleElements,e).sort(((t,e)=>t.element.options.z-e.element.options.z));for(const r of a)un(i,o,s,r);n&&(0,r.$)(i)}function cn(t,e){const n=[];for(const i of t)if(i.options.drawTime===e&&n.push({element:i,main:!0}),i.elements&&i.elements.length)for(const t of i.elements)t.options.display&&t.options.drawTime===e&&n.push({element:t});return n}function un(t,e,n,i){const r=i.element;i.main?(jt(n,r,"beforeDraw"),r.draw(t,e),jt(n,r,"afterDraw")):r.draw(t,e)}},3:function(t,e,n){n.d(e,{j:function(){return s}});var i=n(512);const r=t=>"boolean"===typeof t?`${t}`:0===t?"0":t,o=i.W,s=(t,e)=>n=>{var i;if(null==(null===e||void 0===e?void 0:e.variants))return o(t,null===n||void 0===n?void 0:n.class,null===n||void 0===n?void 0:n.className);const{variants:s,defaultVariants:a}=e,l=Object.keys(s).map((t=>{const e=null===n||void 0===n?void 0:n[t],i=null===a||void 0===a?void 0:a[t];if(null===e)return null;const o=r(e)||r(i);return s[t][o]})),c=n&&Object.entries(n).reduce(((t,e)=>{let[n,i]=e;return void 0===i||(t[n]=i),t}),{}),u=null===e||void 0===e||null===(i=e.compoundVariants)||void 0===i?void 0:i.reduce(((t,e)=>{let{class:n,className:i,...r}=e;return Object.entries(r).every((t=>{let[e,n]=t;return Array.isArray(n)?n.includes({...a,...c}[e]):{...a,...c}[e]===n}))?[...t,n,i]:t}),[]);return o(t,l,u,null===n||void 0===n?void 0:n.class,null===n||void 0===n?void 0:n.className)}},512:function(t,e,n){function i(t){var e,n,r="";if("string"==typeof t||"number"==typeof t)r+=t;else if("object"==typeof t)if(Array.isArray(t)){var o=t.length;for(e=0;e2?n-2:0),r=2;r1?e-1:0),i=1;i1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:x;r&&r(t,null);let i=e.length;while(i--){let r=e[i];if("string"===typeof r){const t=n(r);t!==r&&(o(e)||(e[i]=t),r=t)}t[r]=!0}return t}function O(t){for(let e=0;e/gm),U=c(/\$\{[\w\W]*/gm),q=c(/^data-[\-\w.\u00B7-\uFFFF]+$/),X=c(/^aria-[\-\w]+$/),G=c(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),Z=c(/^(?:\w+script|data):/i),Q=c(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),J=c(/^html$/i),K=c(/^[a-z][.\w]*(-[.\w]+)+$/i);var tt=Object.freeze({__proto__:null,ARIA_ATTR:X,ATTR_WHITESPACE:Q,CUSTOM_ELEMENT:K,DATA_ATTR:q,DOCTYPE_NAME:J,ERB_EXPR:V,IS_ALLOWED_URI:G,IS_SCRIPT_OR_DATA:Z,MUSTACHE_EXPR:Y,TMPLIT_EXPR:U});const et={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},nt=function(){return"undefined"===typeof window?null:window},it=function(t,e){if("object"!==typeof t||"function"!==typeof t.createPolicy)return null;let n=null;const i="data-tt-policy-suffix";e&&e.hasAttribute(i)&&(n=e.getAttribute(i));const r="dompurify"+(n?"#"+n:"");try{return t.createPolicy(r,{createHTML(t){return t},createScriptURL(t){return t}})}catch(o){return console.warn("TrustedTypes policy "+r+" could not be created."),null}},rt=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}};function ot(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:nt();const e=t=>ot(t);if(e.version="3.3.0",e.removed=[],!t||!t.document||t.document.nodeType!==et.document||!t.Element)return e.isSupported=!1,e;let{document:n}=t;const r=n,o=r.currentScript,{DocumentFragment:s,HTMLTemplateElement:a,Node:c,Element:h,NodeFilter:d,NamedNodeMap:D=t.NamedNodeMap||t.MozNamedAttrMap,HTMLFormElement:C,DOMParser:O,trustedTypes:Y}=t,V=h.prototype,U=E(V,"cloneNode"),q=E(V,"remove"),X=E(V,"nextSibling"),Z=E(V,"childNodes"),Q=E(V,"parentNode");if("function"===typeof a){const t=n.createElement("template");t.content&&t.content.ownerDocument&&(n=t.content.ownerDocument)}let K,st="";const{implementation:at,createNodeIterator:lt,createDocumentFragment:ct,getElementsByTagName:ut}=n,{importNode:ht}=r;let dt=rt();e.isSupported="function"===typeof i&&"function"===typeof Q&&at&&void 0!==at.createHTMLDocument;const{MUSTACHE_EXPR:ft,ERB_EXPR:pt,TMPLIT_EXPR:gt,DATA_ATTR:mt,ARIA_ATTR:bt,IS_SCRIPT_OR_DATA:xt,ATTR_WHITESPACE:yt,CUSTOM_ELEMENT:vt}=tt;let{IS_ALLOWED_URI:wt}=tt,kt=null;const _t=A({},[...R,...I,...L,...N,...j]);let Mt=null;const St=A({},[...H,...W,...$,...B]);let Tt=Object.seal(u(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Dt=null,Ct=null;const At=Object.seal(u(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let Ot=!0,Pt=!0,Et=!1,Rt=!0,It=!1,Lt=!0,zt=!1,Nt=!1,Ft=!1,jt=!1,Ht=!1,Wt=!1,$t=!0,Bt=!1;const Yt="user-content-";let Vt=!0,Ut=!1,qt={},Xt=null;const Gt=A({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Zt=null;const Qt=A({},["audio","video","img","source","image","track"]);let Jt=null;const Kt=A({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),te="http://www.w3.org/1998/Math/MathML",ee="http://www.w3.org/2000/svg",ne="http://www.w3.org/1999/xhtml";let ie=ne,re=!1,oe=null;const se=A({},[te,ee,ne],y);let ae=A({},["mi","mo","mn","ms","mtext"]),le=A({},["annotation-xml"]);const ce=A({},["title","style","font","a","script"]);let ue=null;const he=["application/xhtml+xml","text/html"],de="text/html";let fe=null,pe=null;const ge=n.createElement("form"),me=function(t){return t instanceof RegExp||t instanceof Function},be=function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!pe||pe!==t){if(t&&"object"===typeof t||(t={}),t=P(t),ue=-1===he.indexOf(t.PARSER_MEDIA_TYPE)?de:t.PARSER_MEDIA_TYPE,fe="application/xhtml+xml"===ue?y:x,kt=M(t,"ALLOWED_TAGS")?A({},t.ALLOWED_TAGS,fe):_t,Mt=M(t,"ALLOWED_ATTR")?A({},t.ALLOWED_ATTR,fe):St,oe=M(t,"ALLOWED_NAMESPACES")?A({},t.ALLOWED_NAMESPACES,y):se,Jt=M(t,"ADD_URI_SAFE_ATTR")?A(P(Kt),t.ADD_URI_SAFE_ATTR,fe):Kt,Zt=M(t,"ADD_DATA_URI_TAGS")?A(P(Qt),t.ADD_DATA_URI_TAGS,fe):Qt,Xt=M(t,"FORBID_CONTENTS")?A({},t.FORBID_CONTENTS,fe):Gt,Dt=M(t,"FORBID_TAGS")?A({},t.FORBID_TAGS,fe):P({}),Ct=M(t,"FORBID_ATTR")?A({},t.FORBID_ATTR,fe):P({}),qt=!!M(t,"USE_PROFILES")&&t.USE_PROFILES,Ot=!1!==t.ALLOW_ARIA_ATTR,Pt=!1!==t.ALLOW_DATA_ATTR,Et=t.ALLOW_UNKNOWN_PROTOCOLS||!1,Rt=!1!==t.ALLOW_SELF_CLOSE_IN_ATTR,It=t.SAFE_FOR_TEMPLATES||!1,Lt=!1!==t.SAFE_FOR_XML,zt=t.WHOLE_DOCUMENT||!1,jt=t.RETURN_DOM||!1,Ht=t.RETURN_DOM_FRAGMENT||!1,Wt=t.RETURN_TRUSTED_TYPE||!1,Ft=t.FORCE_BODY||!1,$t=!1!==t.SANITIZE_DOM,Bt=t.SANITIZE_NAMED_PROPS||!1,Vt=!1!==t.KEEP_CONTENT,Ut=t.IN_PLACE||!1,wt=t.ALLOWED_URI_REGEXP||G,ie=t.NAMESPACE||ne,ae=t.MATHML_TEXT_INTEGRATION_POINTS||ae,le=t.HTML_INTEGRATION_POINTS||le,Tt=t.CUSTOM_ELEMENT_HANDLING||{},t.CUSTOM_ELEMENT_HANDLING&&me(t.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Tt.tagNameCheck=t.CUSTOM_ELEMENT_HANDLING.tagNameCheck),t.CUSTOM_ELEMENT_HANDLING&&me(t.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Tt.attributeNameCheck=t.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),t.CUSTOM_ELEMENT_HANDLING&&"boolean"===typeof t.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Tt.allowCustomizedBuiltInElements=t.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),It&&(Pt=!1),Ht&&(jt=!0),qt&&(kt=A({},j),Mt=[],!0===qt.html&&(A(kt,R),A(Mt,H)),!0===qt.svg&&(A(kt,I),A(Mt,W),A(Mt,B)),!0===qt.svgFilters&&(A(kt,L),A(Mt,W),A(Mt,B)),!0===qt.mathMl&&(A(kt,N),A(Mt,$),A(Mt,B))),t.ADD_TAGS&&("function"===typeof t.ADD_TAGS?At.tagCheck=t.ADD_TAGS:(kt===_t&&(kt=P(kt)),A(kt,t.ADD_TAGS,fe))),t.ADD_ATTR&&("function"===typeof t.ADD_ATTR?At.attributeCheck=t.ADD_ATTR:(Mt===St&&(Mt=P(Mt)),A(Mt,t.ADD_ATTR,fe))),t.ADD_URI_SAFE_ATTR&&A(Jt,t.ADD_URI_SAFE_ATTR,fe),t.FORBID_CONTENTS&&(Xt===Gt&&(Xt=P(Xt)),A(Xt,t.FORBID_CONTENTS,fe)),Vt&&(kt["#text"]=!0),zt&&A(kt,["html","head","body"]),kt.table&&(A(kt,["tbody"]),delete Dt.tbody),t.TRUSTED_TYPES_POLICY){if("function"!==typeof t.TRUSTED_TYPES_POLICY.createHTML)throw T('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!==typeof t.TRUSTED_TYPES_POLICY.createScriptURL)throw T('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');K=t.TRUSTED_TYPES_POLICY,st=K.createHTML("")}else void 0===K&&(K=it(Y,o)),null!==K&&"string"===typeof st&&(st=K.createHTML(""));l&&l(t),pe=t}},xe=A({},[...I,...L,...z]),ye=A({},[...N,...F]),ve=function(t){let e=Q(t);e&&e.tagName||(e={namespaceURI:ie,tagName:"template"});const n=x(t.tagName),i=x(e.tagName);return!!oe[t.namespaceURI]&&(t.namespaceURI===ee?e.namespaceURI===ne?"svg"===n:e.namespaceURI===te?"svg"===n&&("annotation-xml"===i||ae[i]):Boolean(xe[n]):t.namespaceURI===te?e.namespaceURI===ne?"math"===n:e.namespaceURI===ee?"math"===n&&le[i]:Boolean(ye[n]):t.namespaceURI===ne?!(e.namespaceURI===ee&&!le[i])&&(!(e.namespaceURI===te&&!ae[i])&&(!ye[n]&&(ce[n]||!xe[n]))):!("application/xhtml+xml"!==ue||!oe[t.namespaceURI]))},we=function(t){m(e.removed,{element:t});try{Q(t).removeChild(t)}catch(n){q(t)}},ke=function(t,n){try{m(e.removed,{attribute:n.getAttributeNode(t),from:n})}catch(i){m(e.removed,{attribute:null,from:n})}if(n.removeAttribute(t),"is"===t)if(jt||Ht)try{we(n)}catch(i){}else try{n.setAttribute(t,"")}catch(i){}},_e=function(t){let e=null,i=null;if(Ft)t=" "+t;else{const e=v(t,/^[\r\n\t ]+/);i=e&&e[0]}"application/xhtml+xml"===ue&&ie===ne&&(t=''+t+"");const r=K?K.createHTML(t):t;if(ie===ne)try{e=(new O).parseFromString(r,ue)}catch(s){}if(!e||!e.documentElement){e=at.createDocument(ie,"template",null);try{e.documentElement.innerHTML=re?st:r}catch(s){}}const o=e.body||e.documentElement;return t&&i&&o.insertBefore(n.createTextNode(i),o.childNodes[0]||null),ie===ne?ut.call(e,zt?"html":"body")[0]:zt?e.documentElement:o},Me=function(t){return lt.call(t.ownerDocument||t,t,d.SHOW_ELEMENT|d.SHOW_COMMENT|d.SHOW_TEXT|d.SHOW_PROCESSING_INSTRUCTION|d.SHOW_CDATA_SECTION,null)},Se=function(t){return t instanceof C&&("string"!==typeof t.nodeName||"string"!==typeof t.textContent||"function"!==typeof t.removeChild||!(t.attributes instanceof D)||"function"!==typeof t.removeAttribute||"function"!==typeof t.setAttribute||"string"!==typeof t.namespaceURI||"function"!==typeof t.insertBefore||"function"!==typeof t.hasChildNodes)},Te=function(t){return"function"===typeof c&&t instanceof c};function De(t,n,i){f(t,(t=>{t.call(e,n,i,pe)}))}const Ce=function(t){let n=null;if(De(dt.beforeSanitizeElements,t,null),Se(t))return we(t),!0;const i=fe(t.nodeName);if(De(dt.uponSanitizeElement,t,{tagName:i,allowedTags:kt}),Lt&&t.hasChildNodes()&&!Te(t.firstElementChild)&&S(/<[/\w!]/g,t.innerHTML)&&S(/<[/\w!]/g,t.textContent))return we(t),!0;if(t.nodeType===et.progressingInstruction)return we(t),!0;if(Lt&&t.nodeType===et.comment&&S(/<[/\w]/g,t.data))return we(t),!0;if(!(At.tagCheck instanceof Function&&At.tagCheck(i))&&(!kt[i]||Dt[i])){if(!Dt[i]&&Oe(i)){if(Tt.tagNameCheck instanceof RegExp&&S(Tt.tagNameCheck,i))return!1;if(Tt.tagNameCheck instanceof Function&&Tt.tagNameCheck(i))return!1}if(Vt&&!Xt[i]){const e=Q(t)||t.parentNode,n=Z(t)||t.childNodes;if(n&&e){const i=n.length;for(let r=i-1;r>=0;--r){const i=U(n[r],!0);i.__removalCount=(t.__removalCount||0)+1,e.insertBefore(i,X(t))}}}return we(t),!0}return t instanceof h&&!ve(t)?(we(t),!0):"noscript"!==i&&"noembed"!==i&&"noframes"!==i||!S(/<\/no(script|embed|frames)/i,t.innerHTML)?(It&&t.nodeType===et.text&&(n=t.textContent,f([ft,pt,gt],(t=>{n=w(n,t," ")})),t.textContent!==n&&(m(e.removed,{element:t.cloneNode()}),t.textContent=n)),De(dt.afterSanitizeElements,t,null),!1):(we(t),!0)},Ae=function(t,e,i){if($t&&("id"===e||"name"===e)&&(i in n||i in ge))return!1;if(Pt&&!Ct[e]&&S(mt,e));else if(Ot&&S(bt,e));else if(At.attributeCheck instanceof Function&&At.attributeCheck(e,t));else if(!Mt[e]||Ct[e]){if(!(Oe(t)&&(Tt.tagNameCheck instanceof RegExp&&S(Tt.tagNameCheck,t)||Tt.tagNameCheck instanceof Function&&Tt.tagNameCheck(t))&&(Tt.attributeNameCheck instanceof RegExp&&S(Tt.attributeNameCheck,e)||Tt.attributeNameCheck instanceof Function&&Tt.attributeNameCheck(e,t))||"is"===e&&Tt.allowCustomizedBuiltInElements&&(Tt.tagNameCheck instanceof RegExp&&S(Tt.tagNameCheck,i)||Tt.tagNameCheck instanceof Function&&Tt.tagNameCheck(i))))return!1}else if(Jt[e]);else if(S(wt,w(i,yt,"")));else if("src"!==e&&"xlink:href"!==e&&"href"!==e||"script"===t||0!==k(i,"data:")||!Zt[t]){if(Et&&!S(xt,w(i,yt,"")));else if(i)return!1}else;return!0},Oe=function(t){return"annotation-xml"!==t&&v(t,vt)},Pe=function(t){De(dt.beforeSanitizeAttributes,t,null);const{attributes:n}=t;if(!n||Se(t))return;const i={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Mt,forceKeepAttr:void 0};let r=n.length;while(r--){const s=n[r],{name:a,namespaceURI:l,value:c}=s,u=fe(a),h=c;let d="value"===a?h:_(h);if(i.attrName=u,i.attrValue=d,i.keepAttr=!0,i.forceKeepAttr=void 0,De(dt.uponSanitizeAttribute,t,i),d=i.attrValue,!Bt||"id"!==u&&"name"!==u||(ke(a,t),d=Yt+d),Lt&&S(/((--!?|])>)|<\/(style|title|textarea)/i,d)){ke(a,t);continue}if("attributename"===u&&v(d,"href")){ke(a,t);continue}if(i.forceKeepAttr)continue;if(!i.keepAttr){ke(a,t);continue}if(!Rt&&S(/\/>/i,d)){ke(a,t);continue}It&&f([ft,pt,gt],(t=>{d=w(d,t," ")}));const p=fe(t.nodeName);if(Ae(p,u,d)){if(K&&"object"===typeof Y&&"function"===typeof Y.getAttributeType)if(l);else switch(Y.getAttributeType(p,u)){case"TrustedHTML":d=K.createHTML(d);break;case"TrustedScriptURL":d=K.createScriptURL(d);break}if(d!==h)try{l?t.setAttributeNS(l,a,d):t.setAttribute(a,d),Se(t)?we(t):g(e.removed)}catch(o){ke(a,t)}}else ke(a,t)}De(dt.afterSanitizeAttributes,t,null)},Ee=function t(e){let n=null;const i=Me(e);De(dt.beforeSanitizeShadowDOM,e,null);while(n=i.nextNode())De(dt.uponSanitizeShadowNode,n,null),Ce(n),Pe(n),n.content instanceof s&&t(n.content);De(dt.afterSanitizeShadowDOM,e,null)};return e.sanitize=function(t){let n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=null,o=null,a=null,l=null;if(re=!t,re&&(t="\x3c!--\x3e"),"string"!==typeof t&&!Te(t)){if("function"!==typeof t.toString)throw T("toString is not a function");if(t=t.toString(),"string"!==typeof t)throw T("dirty is not a string, aborting")}if(!e.isSupported)return t;if(Nt||be(n),e.removed=[],"string"===typeof t&&(Ut=!1),Ut){if(t.nodeName){const e=fe(t.nodeName);if(!kt[e]||Dt[e])throw T("root node is forbidden and cannot be sanitized in-place")}}else if(t instanceof c)i=_e("\x3c!----\x3e"),o=i.ownerDocument.importNode(t,!0),o.nodeType===et.element&&"BODY"===o.nodeName||"HTML"===o.nodeName?i=o:i.appendChild(o);else{if(!jt&&!It&&!zt&&-1===t.indexOf("<"))return K&&Wt?K.createHTML(t):t;if(i=_e(t),!i)return jt?null:Wt?st:""}i&&Ft&&we(i.firstChild);const u=Me(Ut?t:i);while(a=u.nextNode())Ce(a),Pe(a),a.content instanceof s&&Ee(a.content);if(Ut)return t;if(jt){if(Ht){l=ct.call(i.ownerDocument);while(i.firstChild)l.appendChild(i.firstChild)}else l=i;return(Mt.shadowroot||Mt.shadowrootmode)&&(l=ht.call(r,l,!0)),l}let h=zt?i.outerHTML:i.innerHTML;return zt&&kt["!doctype"]&&i.ownerDocument&&i.ownerDocument.doctype&&i.ownerDocument.doctype.name&&S(J,i.ownerDocument.doctype.name)&&(h="\n"+h),It&&f([ft,pt,gt],(t=>{h=w(h,t," ")})),K&&Wt?K.createHTML(h):h},e.setConfig=function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};be(t),Nt=!0},e.clearConfig=function(){pe=null,Nt=!1},e.isValidAttribute=function(t,e,n){pe||be({});const i=fe(t),r=fe(e);return Ae(i,r,n)},e.addHook=function(t,e){"function"===typeof e&&m(dt[t],e)},e.removeHook=function(t,e){if(void 0!==e){const n=p(dt[t],e);return-1===n?void 0:b(dt[t],n,1)[0]}return g(dt[t])},e.removeHooks=function(t){dt[t]=[]},e.removeAllHooks=function(){dt=rt()},e}var st=ot()},441:function(t,e,n){function i(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}n.d(e,{TU:function(){return Pt}});var r=i();function o(t){r=t}var s={exec:()=>null};function a(t,e=""){let n="string"==typeof t?t:t.source,i={replace:(t,e)=>{let r="string"==typeof e?e:e.source;return r=r.replace(c.caret,"$1"),n=n.replace(t,r),i},getRegex:()=>new RegExp(n,e)};return i}var l=(()=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:t=>new RegExp(`^( {0,3}${t})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),htmlBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i")},u=/^(?:[ \t]*(?:\n|$))+/,h=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,d=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,f=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,p=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,g=/(?:[*+-]|\d{1,9}[.)])/,m=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,b=a(m).replace(/bull/g,g).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),x=a(m).replace(/bull/g,g).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),y=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,v=/^[^\n]+/,w=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,k=a(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",w).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=a(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,g).getRegex(),M="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,T=a("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",S).replace("tag",M).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),D=a(y).replace("hr",f).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",M).getRegex(),C=a(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",D).getRegex(),A={blockquote:C,code:h,def:k,fences:d,heading:p,hr:f,html:T,lheading:b,list:_,newline:u,paragraph:D,table:s,text:v},O=a("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",f).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",M).getRegex(),P={...A,lheading:x,table:O,paragraph:a(y).replace("hr",f).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",O).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",M).getRegex()},E={...A,html:a("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:a(y).replace("hr",f).replace("heading"," *#{1,6} *[^\n]").replace("lheading",b).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},R=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,I=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,L=/^( {2,}|\\)\n(?!\s*$)/,z=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k (?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",l?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),V=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,U=a(V,"u").replace(/punct/g,N).getRegex(),q=a(V,"u").replace(/punct/g,W).getRegex(),X="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",G=a(X,"gu").replace(/notPunctSpace/g,j).replace(/punctSpace/g,F).replace(/punct/g,N).getRegex(),Z=a(X,"gu").replace(/notPunctSpace/g,B).replace(/punctSpace/g,$).replace(/punct/g,W).getRegex(),Q=a("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,j).replace(/punctSpace/g,F).replace(/punct/g,N).getRegex(),J=a(/\\(punct)/,"gu").replace(/punct/g,N).getRegex(),K=a(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),tt=a(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),et=a("^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",tt).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),nt=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+[^`]*?`+(?!`)|[^\[\]\\`])*?/,it=a(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",nt).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),rt=a(/^!?\[(label)\]\[(ref)\]/).replace("label",nt).replace("ref",w).getRegex(),ot=a(/^!?\[(ref)\](?:\[\])?/).replace("ref",w).getRegex(),st=a("reflink|nolink(?!\\()","g").replace("reflink",rt).replace("nolink",ot).getRegex(),at=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,lt={_backpedal:s,anyPunctuation:J,autolink:K,blockSkip:Y,br:L,code:I,del:s,emStrongLDelim:U,emStrongRDelimAst:G,emStrongRDelimUnd:Q,escape:R,link:it,nolink:ot,punctuation:H,reflink:rt,reflinkSearch:st,tag:et,text:z,url:s},ct={...lt,link:a(/^!?\[(label)\]\((.*?)\)/).replace("label",nt).getRegex(),reflink:a(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",nt).getRegex()},ut={...lt,emStrongRDelimAst:Z,emStrongLDelim:q,url:a(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",at).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:a(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},gt=t=>pt[t];function mt(t,e){if(e){if(c.escapeTest.test(t))return t.replace(c.escapeReplace,gt)}else if(c.escapeTestNoEncode.test(t))return t.replace(c.escapeReplaceNoEncode,gt);return t}function bt(t){try{t=encodeURI(t).replace(c.percentDecode,"%")}catch{return null}return t}function xt(t,e){let n=t.replace(c.findPipe,((t,e,n)=>{let i=!1,r=e;for(;--r>=0&&"\\"===n[r];)i=!i;return i?"|":" |"})),i=n.split(c.splitPipe),r=0;if(i[0].trim()||i.shift(),i.length>0&&!i.at(-1)?.trim()&&i.pop(),e)if(i.length>e)i.splice(e);else for(;i.length0?-2:-1}function wt(t,e,n,i,r){let o=e.href,s=e.title||null,a=t[1].replace(r.other.outputLinkReplace,"$1");i.state.inLink=!0;let l={type:"!"===t[0].charAt(0)?"image":"link",raw:n,href:o,title:s,text:a,tokens:i.inlineTokens(a)};return i.state.inLink=!1,l}function kt(t,e,n){let i=t.match(n.other.indentCodeCompensation);if(null===i)return e;let r=i[1];return e.split("\n").map((t=>{let e=t.match(n.other.beginningSpace);if(null===e)return t;let[i]=e;return i.length>=r.length?t.slice(r.length):t})).join("\n")}var _t=class{options;rules;lexer;constructor(t){this.options=t||r}space(t){let e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){let e=this.rules.block.code.exec(t);if(e){let t=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?t:yt(t,"\n")}}}fences(t){let e=this.rules.block.fences.exec(t);if(e){let t=e[0],n=kt(t,e[3]||"",this.rules);return{type:"code",raw:t,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:n}}}heading(t){let e=this.rules.block.heading.exec(t);if(e){let t=e[2].trim();if(this.rules.other.endingHash.test(t)){let e=yt(t,"#");(this.options.pedantic||!e||this.rules.other.endingSpaceChar.test(e))&&(t=e.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:t,tokens:this.lexer.inline(t)}}}hr(t){let e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:yt(e[0],"\n")}}blockquote(t){let e=this.rules.block.blockquote.exec(t);if(e){let t=yt(e[0],"\n").split("\n"),n="",i="",r=[];for(;t.length>0;){let e,o=!1,s=[];for(e=0;e1,r={type:"list",raw:"",ordered:i,start:i?+n.slice(0,-1):"",loose:!1,items:[]};n=i?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=i?n:"[*+-]");let o=this.rules.other.listItemRegex(n),s=!1;for(;t;){let n=!1,i="",a="";if(!(e=o.exec(t))||this.rules.block.hr.test(t))break;i=e[0],t=t.substring(i.length);let l=e[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(t=>" ".repeat(3*t.length))),c=t.split("\n",1)[0],u=!l.trim(),h=0;if(this.options.pedantic?(h=2,a=l.trimStart()):u?h=e[1].length+1:(h=e[2].search(this.rules.other.nonSpaceChar),h=h>4?1:h,a=l.slice(h),h+=e[1].length),u&&this.rules.other.blankLine.test(c)&&(i+=c+"\n",t=t.substring(c.length+1),n=!0),!n){let e=this.rules.other.nextBulletRegex(h),n=this.rules.other.hrRegex(h),r=this.rules.other.fencesBeginRegex(h),o=this.rules.other.headingBeginRegex(h),s=this.rules.other.htmlBeginRegex(h);for(;t;){let d,f=t.split("\n",1)[0];if(c=f,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),d=c):d=c.replace(this.rules.other.tabCharGlobal," "),r.test(c)||o.test(c)||s.test(c)||e.test(c)||n.test(c))break;if(d.search(this.rules.other.nonSpaceChar)>=h||!c.trim())a+="\n"+d.slice(h);else{if(u||l.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||r.test(l)||o.test(l)||n.test(l))break;a+="\n"+c}!u&&!c.trim()&&(u=!0),i+=f+"\n",t=t.substring(f.length+1),l=d.slice(h)}}r.loose||(s?r.loose=!0:this.rules.other.doubleBlankLine.test(i)&&(s=!0));let d,f=null;this.options.gfm&&(f=this.rules.other.listIsTask.exec(a),f&&(d="[ ] "!==f[0],a=a.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:i,task:!!f,checked:d,loose:!1,text:a,tokens:[]}),r.raw+=i}let a=r.items.at(-1);if(!a)return;a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd(),r.raw=r.raw.trimEnd();for(let t=0;t"space"===t.type)),n=e.length>0&&e.some((t=>this.rules.other.anyLine.test(t.raw)));r.loose=n}if(r.loose)for(let t=0;t({text:t,tokens:this.lexer.inline(t),header:!1,align:o.align[e]}))));return o}}lheading(t){let e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:"="===e[2].charAt(0)?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){let e=this.rules.block.paragraph.exec(t);if(e){let t="\n"===e[1].charAt(e[1].length-1)?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:t,tokens:this.lexer.inline(t)}}}text(t){let e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){let e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){let e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){let e=this.rules.inline.link.exec(t);if(e){let t=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(t)){if(!this.rules.other.endAngleBracket.test(t))return;let e=yt(t.slice(0,-1),"\\");if((t.length-e.length)%2===0)return}else{let t=vt(e[2],"()");if(-2===t)return;if(t>-1){let n=(0===e[0].indexOf("!")?5:4)+e[1].length+t;e[2]=e[2].substring(0,t),e[0]=e[0].substring(0,n).trim(),e[3]=""}}let n=e[2],i="";if(this.options.pedantic){let t=this.rules.other.pedanticHrefTitle.exec(n);t&&(n=t[1],i=t[3])}else i=e[3]?e[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(t)?n.slice(1):n.slice(1,-1)),wt(e,{href:n&&n.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let n;if((n=this.rules.inline.reflink.exec(t))||(n=this.rules.inline.nolink.exec(t))){let t=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=e[t.toLowerCase()];if(!i){let t=n[0].charAt(0);return{type:"text",raw:t,text:t}}return wt(n,i,n[0],this.lexer,this.rules)}}emStrong(t,e,n=""){let i=this.rules.inline.emStrongLDelim.exec(t);if(!(!i||i[3]&&n.match(this.rules.other.unicodeAlphaNumeric))&&(!i[1]&&!i[2]||!n||this.rules.inline.punctuation.exec(n))){let n,r,o=[...i[0]].length-1,s=o,a=0,l="*"===i[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(l.lastIndex=0,e=e.slice(-1*t.length+o);null!=(i=l.exec(e));){if(n=i[1]||i[2]||i[3]||i[4]||i[5]||i[6],!n)continue;if(r=[...n].length,i[3]||i[4]){s+=r;continue}if((i[5]||i[6])&&o%3&&!((o+r)%3)){a+=r;continue}if(s-=r,s>0)continue;r=Math.min(r,r+s+a);let e=[...i[0]][0].length,l=t.slice(0,o+i.index+e+r);if(Math.min(o,r)%2){let t=l.slice(1,-1);return{type:"em",raw:l,text:t,tokens:this.lexer.inlineTokens(t)}}let c=l.slice(2,-2);return{type:"strong",raw:l,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(t){let e=this.rules.inline.code.exec(t);if(e){let t=e[2].replace(this.rules.other.newLineCharGlobal," "),n=this.rules.other.nonSpaceChar.test(t),i=this.rules.other.startingSpaceChar.test(t)&&this.rules.other.endingSpaceChar.test(t);return n&&i&&(t=t.substring(1,t.length-1)),{type:"codespan",raw:e[0],text:t}}}br(t){let e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t){let e=this.rules.inline.del.exec(t);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}}autolink(t){let e=this.rules.inline.autolink.exec(t);if(e){let t,n;return"@"===e[2]?(t=e[1],n="mailto:"+t):(t=e[1],n=t),{type:"link",raw:e[0],text:t,href:n,tokens:[{type:"text",raw:t,text:t}]}}}url(t){let e;if(e=this.rules.inline.url.exec(t)){let t,n;if("@"===e[2])t=e[0],n="mailto:"+t;else{let i;do{i=e[0],e[0]=this.rules.inline._backpedal.exec(e[0])?.[0]??""}while(i!==e[0]);t=e[0],n="www."===e[1]?"http://"+e[0]:e[0]}return{type:"link",raw:e[0],text:t,href:n,tokens:[{type:"text",raw:t,text:t}]}}}inlineText(t){let e=this.rules.inline.text.exec(t);if(e){let t=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:t}}}},Mt=class t{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||r,this.options.tokenizer=this.options.tokenizer||new _t,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let e={other:c,block:dt.normal,inline:ft.normal};this.options.pedantic?(e.block=dt.pedantic,e.inline=ft.pedantic):this.options.gfm&&(e.block=dt.gfm,this.options.breaks?e.inline=ft.breaks:e.inline=ft.gfm),this.tokenizer.rules=e}static get rules(){return{block:dt,inline:ft}}static lex(e,n){return new t(n).lex(e)}static lexInline(e,n){return new t(n).inlineTokens(e)}lex(t){t=t.replace(c.carriageReturn,"\n"),this.blockTokens(t,this.tokens);for(let e=0;e!!(i=n.call({lexer:this},t,e))&&(t=t.substring(i.raw.length),e.push(i),!0))))continue;if(i=this.tokenizer.space(t)){t=t.substring(i.raw.length);let n=e.at(-1);1===i.raw.length&&void 0!==n?n.raw+="\n":e.push(i);continue}if(i=this.tokenizer.code(t)){t=t.substring(i.raw.length);let n=e.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+=(n.raw.endsWith("\n")?"":"\n")+i.raw,n.text+="\n"+i.text,this.inlineQueue.at(-1).src=n.text):e.push(i);continue}if(i=this.tokenizer.fences(t)){t=t.substring(i.raw.length),e.push(i);continue}if(i=this.tokenizer.heading(t)){t=t.substring(i.raw.length),e.push(i);continue}if(i=this.tokenizer.hr(t)){t=t.substring(i.raw.length),e.push(i);continue}if(i=this.tokenizer.blockquote(t)){t=t.substring(i.raw.length),e.push(i);continue}if(i=this.tokenizer.list(t)){t=t.substring(i.raw.length),e.push(i);continue}if(i=this.tokenizer.html(t)){t=t.substring(i.raw.length),e.push(i);continue}if(i=this.tokenizer.def(t)){t=t.substring(i.raw.length);let n=e.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+=(n.raw.endsWith("\n")?"":"\n")+i.raw,n.text+="\n"+i.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[i.tag]||(this.tokens.links[i.tag]={href:i.href,title:i.title},e.push(i));continue}if(i=this.tokenizer.table(t)){t=t.substring(i.raw.length),e.push(i);continue}if(i=this.tokenizer.lheading(t)){t=t.substring(i.raw.length),e.push(i);continue}let r=t;if(this.options.extensions?.startBlock){let e,n=1/0,i=t.slice(1);this.options.extensions.startBlock.forEach((t=>{e=t.call({lexer:this},i),"number"==typeof e&&e>=0&&(n=Math.min(n,e))})),n<1/0&&n>=0&&(r=t.substring(0,n+1))}if(this.state.top&&(i=this.tokenizer.paragraph(r))){let o=e.at(-1);n&&"paragraph"===o?.type?(o.raw+=(o.raw.endsWith("\n")?"":"\n")+i.raw,o.text+="\n"+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):e.push(i),n=r.length!==t.length,t=t.substring(i.raw.length)}else if(i=this.tokenizer.text(t)){t=t.substring(i.raw.length);let n=e.at(-1);"text"===n?.type?(n.raw+=(n.raw.endsWith("\n")?"":"\n")+i.raw,n.text+="\n"+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):e.push(i)}else if(t){let e="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(e);break}throw new Error(e)}}return this.state.top=!0,e}inline(t,e=[]){return this.inlineQueue.push({src:t,tokens:e}),e}inlineTokens(t,e=[]){let n,i=t,r=null;if(this.tokens.links){let t=Object.keys(this.tokens.links);if(t.length>0)for(;null!=(r=this.tokenizer.rules.inline.reflinkSearch.exec(i));)t.includes(r[0].slice(r[0].lastIndexOf("[")+1,-1))&&(i=i.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+i.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(r=this.tokenizer.rules.inline.anyPunctuation.exec(i));)i=i.slice(0,r.index)+"++"+i.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;null!=(r=this.tokenizer.rules.inline.blockSkip.exec(i));)n=r[2]?r[2].length:0,i=i.slice(0,r.index+n)+"["+"a".repeat(r[0].length-n-2)+"]"+i.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);i=this.options.hooks?.emStrongMask?.call({lexer:this},i)??i;let o=!1,s="";for(;t;){let n;if(o||(s=""),o=!1,this.options.extensions?.inline?.some((i=>!!(n=i.call({lexer:this},t,e))&&(t=t.substring(n.raw.length),e.push(n),!0))))continue;if(n=this.tokenizer.escape(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.tag(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.link(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(n.raw.length);let i=e.at(-1);"text"===n.type&&"text"===i?.type?(i.raw+=n.raw,i.text+=n.text):e.push(n);continue}if(n=this.tokenizer.emStrong(t,i,s)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.codespan(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.br(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.del(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.autolink(t)){t=t.substring(n.raw.length),e.push(n);continue}if(!this.state.inLink&&(n=this.tokenizer.url(t))){t=t.substring(n.raw.length),e.push(n);continue}let r=t;if(this.options.extensions?.startInline){let e,n=1/0,i=t.slice(1);this.options.extensions.startInline.forEach((t=>{e=t.call({lexer:this},i),"number"==typeof e&&e>=0&&(n=Math.min(n,e))})),n<1/0&&n>=0&&(r=t.substring(0,n+1))}if(n=this.tokenizer.inlineText(r)){t=t.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(s=n.raw.slice(-1)),o=!0;let i=e.at(-1);"text"===i?.type?(i.raw+=n.raw,i.text+=n.text):e.push(n)}else if(t){let e="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(e);break}throw new Error(e)}}return e}},St=class{options;parser;constructor(t){this.options=t||r}space(t){return""}code({text:t,lang:e,escaped:n}){let i=(e||"").match(c.notSpaceStart)?.[0],r=t.replace(c.endingNewline,"")+"\n";return i?''+(n?r:mt(r,!0))+" \n":""+(n?r:mt(r,!0))+" \n"}blockquote({tokens:t}){return`\n${this.parser.parse(t)} \n`}html({text:t}){return t}def(t){return""}heading({tokens:t,depth:e}){return`${this.parser.parseInline(t)} \n`}hr(t){return" \n"}list(t){let e=t.ordered,n=t.start,i="";for(let s=0;s\n"+i+""+r+">\n"}listitem(t){let e="";if(t.task){let n=this.checkbox({checked:!!t.checked});t.loose?"paragraph"===t.tokens[0]?.type?(t.tokens[0].text=n+" "+t.tokens[0].text,t.tokens[0].tokens&&t.tokens[0].tokens.length>0&&"text"===t.tokens[0].tokens[0].type&&(t.tokens[0].tokens[0].text=n+" "+mt(t.tokens[0].tokens[0].text),t.tokens[0].tokens[0].escaped=!0)):t.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):e+=n+" "}return e+=this.parser.parse(t.tokens,!!t.loose),`${e} \n`}checkbox({checked:t}){return" '}paragraph({tokens:t}){return`${this.parser.parseInline(t)}
\n`}table(t){let e="",n="";for(let r=0;r${i}`),"\n"}tablerow({text:t}){return`\n${t} \n`}tablecell(t){let e=this.parser.parseInline(t.tokens),n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`${n}>\n`}strong({tokens:t}){return`${this.parser.parseInline(t)} `}em({tokens:t}){return`${this.parser.parseInline(t)} `}codespan({text:t}){return`${mt(t,!0)}`}br(t){return" "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:n}){let i=this.parser.parseInline(n),r=bt(t);if(null===r)return i;t=r;let o='"+i+" ",o}image({href:t,title:e,text:n,tokens:i}){i&&(n=this.parser.parseInline(i,this.parser.textRenderer));let r=bt(t);if(null===r)return mt(n);t=r;let o=` ",o}text(t){return"tokens"in t&&t.tokens?this.parser.parseInline(t.tokens):"escaped"in t&&t.escaped?t.text:mt(t.text)}},Tt=class{strong({text:t}){return t}em({text:t}){return t}codespan({text:t}){return t}del({text:t}){return t}html({text:t}){return t}text({text:t}){return t}link({text:t}){return""+t}image({text:t}){return""+t}br(){return""}},Dt=class t{options;renderer;textRenderer;constructor(t){this.options=t||r,this.options.renderer=this.options.renderer||new St,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new Tt}static parse(e,n){return new t(n).parse(e)}static parseInline(e,n){return new t(n).parseInline(e)}parse(t,e=!0){let n="";for(let i=0;i{let r=t[i].flat(1/0);n=n.concat(this.walkTokens(r,e))})):t.tokens&&(n=n.concat(this.walkTokens(t.tokens,e)))}}return n}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach((t=>{let n={...t};if(n.async=this.defaults.async||n.async||!1,t.extensions&&(t.extensions.forEach((t=>{if(!t.name)throw new Error("extension name required");if("renderer"in t){let n=e.renderers[t.name];e.renderers[t.name]=n?function(...e){let i=t.renderer.apply(this,e);return!1===i&&(i=n.apply(this,e)),i}:t.renderer}if("tokenizer"in t){if(!t.level||"block"!==t.level&&"inline"!==t.level)throw new Error("extension level must be 'block' or 'inline'");let n=e[t.level];n?n.unshift(t.tokenizer):e[t.level]=[t.tokenizer],t.start&&("block"===t.level?e.startBlock?e.startBlock.push(t.start):e.startBlock=[t.start]:"inline"===t.level&&(e.startInline?e.startInline.push(t.start):e.startInline=[t.start]))}"childTokens"in t&&t.childTokens&&(e.childTokens[t.name]=t.childTokens)})),n.extensions=e),t.renderer){let e=this.defaults.renderer||new St(this.defaults);for(let n in t.renderer){if(!(n in e))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;let i=n,r=t.renderer[i],o=e[i];e[i]=(...t)=>{let n=r.apply(e,t);return!1===n&&(n=o.apply(e,t)),n||""}}n.renderer=e}if(t.tokenizer){let e=this.defaults.tokenizer||new _t(this.defaults);for(let n in t.tokenizer){if(!(n in e))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;let i=n,r=t.tokenizer[i],o=e[i];e[i]=(...t)=>{let n=r.apply(e,t);return!1===n&&(n=o.apply(e,t)),n}}n.tokenizer=e}if(t.hooks){let e=this.defaults.hooks||new Ct;for(let n in t.hooks){if(!(n in e))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;let i=n,r=t.hooks[i],o=e[i];Ct.passThroughHooks.has(n)?e[i]=t=>{if(this.defaults.async&&Ct.passThroughHooksRespectAsync.has(n))return(async()=>{let n=await r.call(e,t);return o.call(e,n)})();let i=r.call(e,t);return o.call(e,i)}:e[i]=(...t)=>{if(this.defaults.async)return(async()=>{let n=await r.apply(e,t);return!1===n&&(n=await o.apply(e,t)),n})();let n=r.apply(e,t);return!1===n&&(n=o.apply(e,t)),n}}n.hooks=e}if(t.walkTokens){let e=this.defaults.walkTokens,i=t.walkTokens;n.walkTokens=function(t){let n=[];return n.push(i.call(this,t)),e&&(n=n.concat(e.call(this,t))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return Mt.lex(t,e??this.defaults)}parser(t,e){return Dt.parse(t,e??this.defaults)}parseMarkdown(t){return(e,n)=>{let i={...n},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===i.async)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof e>"u"||null===e)return o(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof e)return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));if(r.hooks&&(r.hooks.options=r,r.hooks.block=t),r.async)return(async()=>{let n=r.hooks?await r.hooks.preprocess(e):e,i=await(r.hooks?await r.hooks.provideLexer():t?Mt.lex:Mt.lexInline)(n,r),o=r.hooks?await r.hooks.processAllTokens(i):i;r.walkTokens&&await Promise.all(this.walkTokens(o,r.walkTokens));let s=await(r.hooks?await r.hooks.provideParser():t?Dt.parse:Dt.parseInline)(o,r);return r.hooks?await r.hooks.postprocess(s):s})().catch(o);try{r.hooks&&(e=r.hooks.preprocess(e));let n=(r.hooks?r.hooks.provideLexer():t?Mt.lex:Mt.lexInline)(e,r);r.hooks&&(n=r.hooks.processAllTokens(n)),r.walkTokens&&this.walkTokens(n,r.walkTokens);let i=(r.hooks?r.hooks.provideParser():t?Dt.parse:Dt.parseInline)(n,r);return r.hooks&&(i=r.hooks.postprocess(i)),i}catch(s){return o(s)}}}onError(t,e){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",t){let t="An error occurred:
"+mt(n.message+"",!0)+" ";return e?Promise.resolve(t):t}if(e)return Promise.reject(n);throw n}}},Ot=new At;function Pt(t,e){return Ot.parse(t,e)}Pt.options=Pt.setOptions=function(t){return Ot.setOptions(t),Pt.defaults=Ot.defaults,o(Pt.defaults),Pt},Pt.getDefaults=i,Pt.defaults=r,Pt.use=function(...t){return Ot.use(...t),Pt.defaults=Ot.defaults,o(Pt.defaults),Pt},Pt.walkTokens=function(t,e){return Ot.walkTokens(t,e)},Pt.parseInline=Ot.parseInline,Pt.Parser=Dt,Pt.parser=Dt.parse,Pt.Renderer=St,Pt.TextRenderer=Tt,Pt.Lexer=Mt,Pt.lexer=Mt.lex,Pt.Tokenizer=_t,Pt.Hooks=Ct,Pt.parse=Pt;Pt.options,Pt.setOptions,Pt.use,Pt.walkTokens,Pt.parseInline,Dt.parse,Mt.lex},388:function(t,e,n){n.d(e,{m6:function(){return gt}});const i="-",r=t=>{const e=l(t),{conflictingClassGroups:n,conflictingClassGroupModifiers:r}=t,s=t=>{const n=t.split(i);return""===n[0]&&1!==n.length&&n.shift(),o(n,e)||a(t)},c=(t,e)=>{const i=n[t]||[];return e&&r[t]?[...i,...r[t]]:i};return{getClassGroupId:s,getConflictingClassGroupIds:c}},o=(t,e)=>{if(0===t.length)return e.classGroupId;const n=t[0],r=e.nextPart.get(n),s=r?o(t.slice(1),r):void 0;if(s)return s;if(0===e.validators.length)return;const a=t.join(i);return e.validators.find((({validator:t})=>t(a)))?.classGroupId},s=/^\[(.+)\]$/,a=t=>{if(s.test(t)){const e=s.exec(t)[1],n=e?.substring(0,e.indexOf(":"));if(n)return"arbitrary.."+n}},l=t=>{const{theme:e,classGroups:n}=t,i={nextPart:new Map,validators:[]};for(const r in n)c(n[r],i,r,e);return i},c=(t,e,n,i)=>{t.forEach((t=>{if("string"!==typeof t){if("function"===typeof t)return h(t)?void c(t(i),e,n,i):void e.validators.push({validator:t,classGroupId:n});Object.entries(t).forEach((([t,r])=>{c(r,u(e,t),n,i)}))}else{const i=""===t?e:u(e,t);i.classGroupId=n}}))},u=(t,e)=>{let n=t;return e.split(i).forEach((t=>{n.nextPart.has(t)||n.nextPart.set(t,{nextPart:new Map,validators:[]}),n=n.nextPart.get(t)})),n},h=t=>t.isThemeGetter,d=t=>{if(t<1)return{get:()=>{},set:()=>{}};let e=0,n=new Map,i=new Map;const r=(r,o)=>{n.set(r,o),e++,e>t&&(e=0,i=n,n=new Map)};return{get(t){let e=n.get(t);return void 0!==e?e:void 0!==(e=i.get(t))?(r(t,e),e):void 0},set(t,e){n.has(t)?n.set(t,e):r(t,e)}}},f="!",p=":",g=p.length,m=t=>{const{prefix:e,experimentalParseClassName:n}=t;let i=t=>{const e=[];let n,i=0,r=0,o=0;for(let u=0;uo?n-o:void 0;return{modifiers:e,hasImportantModifier:l,baseClassName:a,maybePostfixModifierPosition:c}};if(e){const t=e+p,n=i;i=e=>e.startsWith(t)?n(e.substring(t.length)):{isExternal:!0,modifiers:[],hasImportantModifier:!1,baseClassName:e,maybePostfixModifierPosition:void 0}}if(n){const t=i;i=e=>n({className:e,parseClassName:t})}return i},b=t=>t.endsWith(f)?t.substring(0,t.length-1):t.startsWith(f)?t.substring(1):t,x=t=>{const e=Object.fromEntries(t.orderSensitiveModifiers.map((t=>[t,!0]))),n=t=>{if(t.length<=1)return t;const n=[];let i=[];return t.forEach((t=>{const r="["===t[0]||e[t];r?(n.push(...i.sort(),t),i=[]):i.push(t)})),n.push(...i.sort()),n};return n},y=t=>({cache:d(t.cacheSize),parseClassName:m(t),sortModifiers:x(t),...r(t)}),v=/\s+/,w=(t,e)=>{const{parseClassName:n,getClassGroupId:i,getConflictingClassGroupIds:r,sortModifiers:o}=e,s=[],a=t.trim().split(v);let l="";for(let c=a.length-1;c>=0;c-=1){const t=a[c],{isExternal:e,modifiers:u,hasImportantModifier:h,baseClassName:d,maybePostfixModifierPosition:p}=n(t);if(e){l=t+(l.length>0?" "+l:l);continue}let g=!!p,m=i(g?d.substring(0,p):d);if(!m){if(!g){l=t+(l.length>0?" "+l:l);continue}if(m=i(d),!m){l=t+(l.length>0?" "+l:l);continue}g=!1}const b=o(u).join(":"),x=h?b+f:b,y=x+m;if(s.includes(y))continue;s.push(y);const v=r(m,g);for(let n=0;n0?" "+l:l)}return l};function k(){let t,e,n=0,i="";while(n{if("string"===typeof t)return t;let e,n="";for(let i=0;ie(t)),t());return n=y(l),i=n.cache.get,r=n.cache.set,o=a,a(s)}function a(t){const e=i(t);if(e)return e;const o=w(t,n);return r(t,o),o}return function(){return o(k.apply(null,arguments))}}const S=t=>{const e=e=>e[t]||[];return e.isThemeGetter=!0,e},T=/^\[(?:(\w[\w-]*):)?(.+)\]$/i,D=/^\((?:(\w[\w-]*):)?(.+)\)$/i,C=/^\d+\/\d+$/,A=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,O=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,P=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,E=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,R=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,I=t=>C.test(t),L=t=>!!t&&!Number.isNaN(Number(t)),z=t=>!!t&&Number.isInteger(Number(t)),N=t=>t.endsWith("%")&&L(t.slice(0,-1)),F=t=>A.test(t),j=()=>!0,H=t=>O.test(t)&&!P.test(t),W=()=>!1,$=t=>E.test(t),B=t=>R.test(t),Y=t=>!U(t)&&!J(t),V=t=>ot(t,ct,W),U=t=>T.test(t),q=t=>ot(t,ut,H),X=t=>ot(t,ht,L),G=t=>ot(t,at,W),Z=t=>ot(t,lt,B),Q=t=>ot(t,ft,$),J=t=>D.test(t),K=t=>st(t,ut),tt=t=>st(t,dt),et=t=>st(t,at),nt=t=>st(t,ct),it=t=>st(t,lt),rt=t=>st(t,ft,!0),ot=(t,e,n)=>{const i=T.exec(t);return!!i&&(i[1]?e(i[1]):n(i[2]))},st=(t,e,n=!1)=>{const i=D.exec(t);return!!i&&(i[1]?e(i[1]):n)},at=t=>"position"===t||"percentage"===t,lt=t=>"image"===t||"url"===t,ct=t=>"length"===t||"size"===t||"bg-size"===t,ut=t=>"length"===t,ht=t=>"number"===t,dt=t=>"family-name"===t,ft=t=>"shadow"===t,pt=(Symbol.toStringTag,()=>{const t=S("color"),e=S("font"),n=S("text"),i=S("font-weight"),r=S("tracking"),o=S("leading"),s=S("breakpoint"),a=S("container"),l=S("spacing"),c=S("radius"),u=S("shadow"),h=S("inset-shadow"),d=S("text-shadow"),f=S("drop-shadow"),p=S("blur"),g=S("perspective"),m=S("aspect"),b=S("ease"),x=S("animate"),y=()=>["auto","avoid","all","avoid-page","page","left","right","column"],v=()=>["center","top","bottom","left","right","top-left","left-top","top-right","right-top","bottom-right","right-bottom","bottom-left","left-bottom"],w=()=>[...v(),J,U],k=()=>["auto","hidden","clip","visible","scroll"],_=()=>["auto","contain","none"],M=()=>[J,U,l],T=()=>[I,"full","auto",...M()],D=()=>[z,"none","subgrid",J,U],C=()=>["auto",{span:["full",z,J,U]},z,J,U],A=()=>[z,"auto",J,U],O=()=>["auto","min","max","fr",J,U],P=()=>["start","end","center","between","around","evenly","stretch","baseline","center-safe","end-safe"],E=()=>["start","end","center","stretch","center-safe","end-safe"],R=()=>["auto",...M()],H=()=>[I,"auto","full","dvw","dvh","lvw","lvh","svw","svh","min","max","fit",...M()],W=()=>[t,J,U],$=()=>[...v(),et,G,{position:[J,U]}],B=()=>["no-repeat",{repeat:["","x","y","space","round"]}],ot=()=>["auto","cover","contain",nt,V,{size:[J,U]}],st=()=>[N,K,q],at=()=>["","none","full",c,J,U],lt=()=>["",L,K,q],ct=()=>["solid","dashed","dotted","double"],ut=()=>["normal","multiply","screen","overlay","darken","lighten","color-dodge","color-burn","hard-light","soft-light","difference","exclusion","hue","saturation","color","luminosity"],ht=()=>[L,N,et,G],dt=()=>["","none",p,J,U],ft=()=>["none",L,J,U],pt=()=>["none",L,J,U],gt=()=>[L,J,U],mt=()=>[I,"full",...M()];return{cacheSize:500,theme:{animate:["spin","ping","pulse","bounce"],aspect:["video"],blur:[F],breakpoint:[F],color:[j],container:[F],"drop-shadow":[F],ease:["in","out","in-out"],font:[Y],"font-weight":["thin","extralight","light","normal","medium","semibold","bold","extrabold","black"],"inset-shadow":[F],leading:["none","tight","snug","normal","relaxed","loose"],perspective:["dramatic","near","normal","midrange","distant","none"],radius:[F],shadow:[F],spacing:["px",L],text:[F],"text-shadow":[F],tracking:["tighter","tight","normal","wide","wider","widest"]},classGroups:{aspect:[{aspect:["auto","square",I,U,J,m]}],container:["container"],columns:[{columns:[L,U,J,a]}],"break-after":[{"break-after":y()}],"break-before":[{"break-before":y()}],"break-inside":[{"break-inside":["auto","avoid","avoid-page","avoid-column"]}],"box-decoration":[{"box-decoration":["slice","clone"]}],box:[{box:["border","content"]}],display:["block","inline-block","inline","flex","inline-flex","table","inline-table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row-group","table-row","flow-root","grid","inline-grid","contents","list-item","hidden"],sr:["sr-only","not-sr-only"],float:[{float:["right","left","none","start","end"]}],clear:[{clear:["left","right","both","none","start","end"]}],isolation:["isolate","isolation-auto"],"object-fit":[{object:["contain","cover","fill","none","scale-down"]}],"object-position":[{object:w()}],overflow:[{overflow:k()}],"overflow-x":[{"overflow-x":k()}],"overflow-y":[{"overflow-y":k()}],overscroll:[{overscroll:_()}],"overscroll-x":[{"overscroll-x":_()}],"overscroll-y":[{"overscroll-y":_()}],position:["static","fixed","absolute","relative","sticky"],inset:[{inset:T()}],"inset-x":[{"inset-x":T()}],"inset-y":[{"inset-y":T()}],start:[{start:T()}],end:[{end:T()}],top:[{top:T()}],right:[{right:T()}],bottom:[{bottom:T()}],left:[{left:T()}],visibility:["visible","invisible","collapse"],z:[{z:[z,"auto",J,U]}],basis:[{basis:[I,"full","auto",a,...M()]}],"flex-direction":[{flex:["row","row-reverse","col","col-reverse"]}],"flex-wrap":[{flex:["nowrap","wrap","wrap-reverse"]}],flex:[{flex:[L,I,"auto","initial","none",U]}],grow:[{grow:["",L,J,U]}],shrink:[{shrink:["",L,J,U]}],order:[{order:[z,"first","last","none",J,U]}],"grid-cols":[{"grid-cols":D()}],"col-start-end":[{col:C()}],"col-start":[{"col-start":A()}],"col-end":[{"col-end":A()}],"grid-rows":[{"grid-rows":D()}],"row-start-end":[{row:C()}],"row-start":[{"row-start":A()}],"row-end":[{"row-end":A()}],"grid-flow":[{"grid-flow":["row","col","dense","row-dense","col-dense"]}],"auto-cols":[{"auto-cols":O()}],"auto-rows":[{"auto-rows":O()}],gap:[{gap:M()}],"gap-x":[{"gap-x":M()}],"gap-y":[{"gap-y":M()}],"justify-content":[{justify:[...P(),"normal"]}],"justify-items":[{"justify-items":[...E(),"normal"]}],"justify-self":[{"justify-self":["auto",...E()]}],"align-content":[{content:["normal",...P()]}],"align-items":[{items:[...E(),{baseline:["","last"]}]}],"align-self":[{self:["auto",...E(),{baseline:["","last"]}]}],"place-content":[{"place-content":P()}],"place-items":[{"place-items":[...E(),"baseline"]}],"place-self":[{"place-self":["auto",...E()]}],p:[{p:M()}],px:[{px:M()}],py:[{py:M()}],ps:[{ps:M()}],pe:[{pe:M()}],pt:[{pt:M()}],pr:[{pr:M()}],pb:[{pb:M()}],pl:[{pl:M()}],m:[{m:R()}],mx:[{mx:R()}],my:[{my:R()}],ms:[{ms:R()}],me:[{me:R()}],mt:[{mt:R()}],mr:[{mr:R()}],mb:[{mb:R()}],ml:[{ml:R()}],"space-x":[{"space-x":M()}],"space-x-reverse":["space-x-reverse"],"space-y":[{"space-y":M()}],"space-y-reverse":["space-y-reverse"],size:[{size:H()}],w:[{w:[a,"screen",...H()]}],"min-w":[{"min-w":[a,"screen","none",...H()]}],"max-w":[{"max-w":[a,"screen","none","prose",{screen:[s]},...H()]}],h:[{h:["screen","lh",...H()]}],"min-h":[{"min-h":["screen","lh","none",...H()]}],"max-h":[{"max-h":["screen","lh",...H()]}],"font-size":[{text:["base",n,K,q]}],"font-smoothing":["antialiased","subpixel-antialiased"],"font-style":["italic","not-italic"],"font-weight":[{font:[i,J,X]}],"font-stretch":[{"font-stretch":["ultra-condensed","extra-condensed","condensed","semi-condensed","normal","semi-expanded","expanded","extra-expanded","ultra-expanded",N,U]}],"font-family":[{font:[tt,U,e]}],"fvn-normal":["normal-nums"],"fvn-ordinal":["ordinal"],"fvn-slashed-zero":["slashed-zero"],"fvn-figure":["lining-nums","oldstyle-nums"],"fvn-spacing":["proportional-nums","tabular-nums"],"fvn-fraction":["diagonal-fractions","stacked-fractions"],tracking:[{tracking:[r,J,U]}],"line-clamp":[{"line-clamp":[L,"none",J,X]}],leading:[{leading:[o,...M()]}],"list-image":[{"list-image":["none",J,U]}],"list-style-position":[{list:["inside","outside"]}],"list-style-type":[{list:["disc","decimal","none",J,U]}],"text-alignment":[{text:["left","center","right","justify","start","end"]}],"placeholder-color":[{placeholder:W()}],"text-color":[{text:W()}],"text-decoration":["underline","overline","line-through","no-underline"],"text-decoration-style":[{decoration:[...ct(),"wavy"]}],"text-decoration-thickness":[{decoration:[L,"from-font","auto",J,q]}],"text-decoration-color":[{decoration:W()}],"underline-offset":[{"underline-offset":[L,"auto",J,U]}],"text-transform":["uppercase","lowercase","capitalize","normal-case"],"text-overflow":["truncate","text-ellipsis","text-clip"],"text-wrap":[{text:["wrap","nowrap","balance","pretty"]}],indent:[{indent:M()}],"vertical-align":[{align:["baseline","top","middle","bottom","text-top","text-bottom","sub","super",J,U]}],whitespace:[{whitespace:["normal","nowrap","pre","pre-line","pre-wrap","break-spaces"]}],break:[{break:["normal","words","all","keep"]}],wrap:[{wrap:["break-word","anywhere","normal"]}],hyphens:[{hyphens:["none","manual","auto"]}],content:[{content:["none",J,U]}],"bg-attachment":[{bg:["fixed","local","scroll"]}],"bg-clip":[{"bg-clip":["border","padding","content","text"]}],"bg-origin":[{"bg-origin":["border","padding","content"]}],"bg-position":[{bg:$()}],"bg-repeat":[{bg:B()}],"bg-size":[{bg:ot()}],"bg-image":[{bg:["none",{linear:[{to:["t","tr","r","br","b","bl","l","tl"]},z,J,U],radial:["",J,U],conic:[z,J,U]},it,Z]}],"bg-color":[{bg:W()}],"gradient-from-pos":[{from:st()}],"gradient-via-pos":[{via:st()}],"gradient-to-pos":[{to:st()}],"gradient-from":[{from:W()}],"gradient-via":[{via:W()}],"gradient-to":[{to:W()}],rounded:[{rounded:at()}],"rounded-s":[{"rounded-s":at()}],"rounded-e":[{"rounded-e":at()}],"rounded-t":[{"rounded-t":at()}],"rounded-r":[{"rounded-r":at()}],"rounded-b":[{"rounded-b":at()}],"rounded-l":[{"rounded-l":at()}],"rounded-ss":[{"rounded-ss":at()}],"rounded-se":[{"rounded-se":at()}],"rounded-ee":[{"rounded-ee":at()}],"rounded-es":[{"rounded-es":at()}],"rounded-tl":[{"rounded-tl":at()}],"rounded-tr":[{"rounded-tr":at()}],"rounded-br":[{"rounded-br":at()}],"rounded-bl":[{"rounded-bl":at()}],"border-w":[{border:lt()}],"border-w-x":[{"border-x":lt()}],"border-w-y":[{"border-y":lt()}],"border-w-s":[{"border-s":lt()}],"border-w-e":[{"border-e":lt()}],"border-w-t":[{"border-t":lt()}],"border-w-r":[{"border-r":lt()}],"border-w-b":[{"border-b":lt()}],"border-w-l":[{"border-l":lt()}],"divide-x":[{"divide-x":lt()}],"divide-x-reverse":["divide-x-reverse"],"divide-y":[{"divide-y":lt()}],"divide-y-reverse":["divide-y-reverse"],"border-style":[{border:[...ct(),"hidden","none"]}],"divide-style":[{divide:[...ct(),"hidden","none"]}],"border-color":[{border:W()}],"border-color-x":[{"border-x":W()}],"border-color-y":[{"border-y":W()}],"border-color-s":[{"border-s":W()}],"border-color-e":[{"border-e":W()}],"border-color-t":[{"border-t":W()}],"border-color-r":[{"border-r":W()}],"border-color-b":[{"border-b":W()}],"border-color-l":[{"border-l":W()}],"divide-color":[{divide:W()}],"outline-style":[{outline:[...ct(),"none","hidden"]}],"outline-offset":[{"outline-offset":[L,J,U]}],"outline-w":[{outline:["",L,K,q]}],"outline-color":[{outline:W()}],shadow:[{shadow:["","none",u,rt,Q]}],"shadow-color":[{shadow:W()}],"inset-shadow":[{"inset-shadow":["none",h,rt,Q]}],"inset-shadow-color":[{"inset-shadow":W()}],"ring-w":[{ring:lt()}],"ring-w-inset":["ring-inset"],"ring-color":[{ring:W()}],"ring-offset-w":[{"ring-offset":[L,q]}],"ring-offset-color":[{"ring-offset":W()}],"inset-ring-w":[{"inset-ring":lt()}],"inset-ring-color":[{"inset-ring":W()}],"text-shadow":[{"text-shadow":["none",d,rt,Q]}],"text-shadow-color":[{"text-shadow":W()}],opacity:[{opacity:[L,J,U]}],"mix-blend":[{"mix-blend":[...ut(),"plus-darker","plus-lighter"]}],"bg-blend":[{"bg-blend":ut()}],"mask-clip":[{"mask-clip":["border","padding","content","fill","stroke","view"]},"mask-no-clip"],"mask-composite":[{mask:["add","subtract","intersect","exclude"]}],"mask-image-linear-pos":[{"mask-linear":[L]}],"mask-image-linear-from-pos":[{"mask-linear-from":ht()}],"mask-image-linear-to-pos":[{"mask-linear-to":ht()}],"mask-image-linear-from-color":[{"mask-linear-from":W()}],"mask-image-linear-to-color":[{"mask-linear-to":W()}],"mask-image-t-from-pos":[{"mask-t-from":ht()}],"mask-image-t-to-pos":[{"mask-t-to":ht()}],"mask-image-t-from-color":[{"mask-t-from":W()}],"mask-image-t-to-color":[{"mask-t-to":W()}],"mask-image-r-from-pos":[{"mask-r-from":ht()}],"mask-image-r-to-pos":[{"mask-r-to":ht()}],"mask-image-r-from-color":[{"mask-r-from":W()}],"mask-image-r-to-color":[{"mask-r-to":W()}],"mask-image-b-from-pos":[{"mask-b-from":ht()}],"mask-image-b-to-pos":[{"mask-b-to":ht()}],"mask-image-b-from-color":[{"mask-b-from":W()}],"mask-image-b-to-color":[{"mask-b-to":W()}],"mask-image-l-from-pos":[{"mask-l-from":ht()}],"mask-image-l-to-pos":[{"mask-l-to":ht()}],"mask-image-l-from-color":[{"mask-l-from":W()}],"mask-image-l-to-color":[{"mask-l-to":W()}],"mask-image-x-from-pos":[{"mask-x-from":ht()}],"mask-image-x-to-pos":[{"mask-x-to":ht()}],"mask-image-x-from-color":[{"mask-x-from":W()}],"mask-image-x-to-color":[{"mask-x-to":W()}],"mask-image-y-from-pos":[{"mask-y-from":ht()}],"mask-image-y-to-pos":[{"mask-y-to":ht()}],"mask-image-y-from-color":[{"mask-y-from":W()}],"mask-image-y-to-color":[{"mask-y-to":W()}],"mask-image-radial":[{"mask-radial":[J,U]}],"mask-image-radial-from-pos":[{"mask-radial-from":ht()}],"mask-image-radial-to-pos":[{"mask-radial-to":ht()}],"mask-image-radial-from-color":[{"mask-radial-from":W()}],"mask-image-radial-to-color":[{"mask-radial-to":W()}],"mask-image-radial-shape":[{"mask-radial":["circle","ellipse"]}],"mask-image-radial-size":[{"mask-radial":[{closest:["side","corner"],farthest:["side","corner"]}]}],"mask-image-radial-pos":[{"mask-radial-at":v()}],"mask-image-conic-pos":[{"mask-conic":[L]}],"mask-image-conic-from-pos":[{"mask-conic-from":ht()}],"mask-image-conic-to-pos":[{"mask-conic-to":ht()}],"mask-image-conic-from-color":[{"mask-conic-from":W()}],"mask-image-conic-to-color":[{"mask-conic-to":W()}],"mask-mode":[{mask:["alpha","luminance","match"]}],"mask-origin":[{"mask-origin":["border","padding","content","fill","stroke","view"]}],"mask-position":[{mask:$()}],"mask-repeat":[{mask:B()}],"mask-size":[{mask:ot()}],"mask-type":[{"mask-type":["alpha","luminance"]}],"mask-image":[{mask:["none",J,U]}],filter:[{filter:["","none",J,U]}],blur:[{blur:dt()}],brightness:[{brightness:[L,J,U]}],contrast:[{contrast:[L,J,U]}],"drop-shadow":[{"drop-shadow":["","none",f,rt,Q]}],"drop-shadow-color":[{"drop-shadow":W()}],grayscale:[{grayscale:["",L,J,U]}],"hue-rotate":[{"hue-rotate":[L,J,U]}],invert:[{invert:["",L,J,U]}],saturate:[{saturate:[L,J,U]}],sepia:[{sepia:["",L,J,U]}],"backdrop-filter":[{"backdrop-filter":["","none",J,U]}],"backdrop-blur":[{"backdrop-blur":dt()}],"backdrop-brightness":[{"backdrop-brightness":[L,J,U]}],"backdrop-contrast":[{"backdrop-contrast":[L,J,U]}],"backdrop-grayscale":[{"backdrop-grayscale":["",L,J,U]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[L,J,U]}],"backdrop-invert":[{"backdrop-invert":["",L,J,U]}],"backdrop-opacity":[{"backdrop-opacity":[L,J,U]}],"backdrop-saturate":[{"backdrop-saturate":[L,J,U]}],"backdrop-sepia":[{"backdrop-sepia":["",L,J,U]}],"border-collapse":[{border:["collapse","separate"]}],"border-spacing":[{"border-spacing":M()}],"border-spacing-x":[{"border-spacing-x":M()}],"border-spacing-y":[{"border-spacing-y":M()}],"table-layout":[{table:["auto","fixed"]}],caption:[{caption:["top","bottom"]}],transition:[{transition:["","all","colors","opacity","shadow","transform","none",J,U]}],"transition-behavior":[{transition:["normal","discrete"]}],duration:[{duration:[L,"initial",J,U]}],ease:[{ease:["linear","initial",b,J,U]}],delay:[{delay:[L,J,U]}],animate:[{animate:["none",x,J,U]}],backface:[{backface:["hidden","visible"]}],perspective:[{perspective:[g,J,U]}],"perspective-origin":[{"perspective-origin":w()}],rotate:[{rotate:ft()}],"rotate-x":[{"rotate-x":ft()}],"rotate-y":[{"rotate-y":ft()}],"rotate-z":[{"rotate-z":ft()}],scale:[{scale:pt()}],"scale-x":[{"scale-x":pt()}],"scale-y":[{"scale-y":pt()}],"scale-z":[{"scale-z":pt()}],"scale-3d":["scale-3d"],skew:[{skew:gt()}],"skew-x":[{"skew-x":gt()}],"skew-y":[{"skew-y":gt()}],transform:[{transform:[J,U,"","none","gpu","cpu"]}],"transform-origin":[{origin:w()}],"transform-style":[{transform:["3d","flat"]}],translate:[{translate:mt()}],"translate-x":[{"translate-x":mt()}],"translate-y":[{"translate-y":mt()}],"translate-z":[{"translate-z":mt()}],"translate-none":["translate-none"],accent:[{accent:W()}],appearance:[{appearance:["none","auto"]}],"caret-color":[{caret:W()}],"color-scheme":[{scheme:["normal","dark","light","light-dark","only-dark","only-light"]}],cursor:[{cursor:["auto","default","pointer","wait","text","move","help","not-allowed","none","context-menu","progress","cell","crosshair","vertical-text","alias","copy","no-drop","grab","grabbing","all-scroll","col-resize","row-resize","n-resize","e-resize","s-resize","w-resize","ne-resize","nw-resize","se-resize","sw-resize","ew-resize","ns-resize","nesw-resize","nwse-resize","zoom-in","zoom-out",J,U]}],"field-sizing":[{"field-sizing":["fixed","content"]}],"pointer-events":[{"pointer-events":["auto","none"]}],resize:[{resize:["none","","y","x"]}],"scroll-behavior":[{scroll:["auto","smooth"]}],"scroll-m":[{"scroll-m":M()}],"scroll-mx":[{"scroll-mx":M()}],"scroll-my":[{"scroll-my":M()}],"scroll-ms":[{"scroll-ms":M()}],"scroll-me":[{"scroll-me":M()}],"scroll-mt":[{"scroll-mt":M()}],"scroll-mr":[{"scroll-mr":M()}],"scroll-mb":[{"scroll-mb":M()}],"scroll-ml":[{"scroll-ml":M()}],"scroll-p":[{"scroll-p":M()}],"scroll-px":[{"scroll-px":M()}],"scroll-py":[{"scroll-py":M()}],"scroll-ps":[{"scroll-ps":M()}],"scroll-pe":[{"scroll-pe":M()}],"scroll-pt":[{"scroll-pt":M()}],"scroll-pr":[{"scroll-pr":M()}],"scroll-pb":[{"scroll-pb":M()}],"scroll-pl":[{"scroll-pl":M()}],"snap-align":[{snap:["start","end","center","align-none"]}],"snap-stop":[{snap:["normal","always"]}],"snap-type":[{snap:["none","x","y","both"]}],"snap-strictness":[{snap:["mandatory","proximity"]}],touch:[{touch:["auto","none","manipulation"]}],"touch-x":[{"touch-pan":["x","left","right"]}],"touch-y":[{"touch-pan":["y","up","down"]}],"touch-pz":["touch-pinch-zoom"],select:[{select:["none","text","all","auto"]}],"will-change":[{"will-change":["auto","scroll","contents","transform",J,U]}],fill:[{fill:["none",...W()]}],"stroke-w":[{stroke:[L,K,q,X]}],stroke:[{stroke:["none",...W()]}],"forced-color-adjust":[{"forced-color-adjust":["auto","none"]}]},conflictingClassGroups:{overflow:["overflow-x","overflow-y"],overscroll:["overscroll-x","overscroll-y"],inset:["inset-x","inset-y","start","end","top","right","bottom","left"],"inset-x":["right","left"],"inset-y":["top","bottom"],flex:["basis","grow","shrink"],gap:["gap-x","gap-y"],p:["px","py","ps","pe","pt","pr","pb","pl"],px:["pr","pl"],py:["pt","pb"],m:["mx","my","ms","me","mt","mr","mb","ml"],mx:["mr","ml"],my:["mt","mb"],size:["w","h"],"font-size":["leading"],"fvn-normal":["fvn-ordinal","fvn-slashed-zero","fvn-figure","fvn-spacing","fvn-fraction"],"fvn-ordinal":["fvn-normal"],"fvn-slashed-zero":["fvn-normal"],"fvn-figure":["fvn-normal"],"fvn-spacing":["fvn-normal"],"fvn-fraction":["fvn-normal"],"line-clamp":["display","overflow"],rounded:["rounded-s","rounded-e","rounded-t","rounded-r","rounded-b","rounded-l","rounded-ss","rounded-se","rounded-ee","rounded-es","rounded-tl","rounded-tr","rounded-br","rounded-bl"],"rounded-s":["rounded-ss","rounded-es"],"rounded-e":["rounded-se","rounded-ee"],"rounded-t":["rounded-tl","rounded-tr"],"rounded-r":["rounded-tr","rounded-br"],"rounded-b":["rounded-br","rounded-bl"],"rounded-l":["rounded-tl","rounded-bl"],"border-spacing":["border-spacing-x","border-spacing-y"],"border-w":["border-w-x","border-w-y","border-w-s","border-w-e","border-w-t","border-w-r","border-w-b","border-w-l"],"border-w-x":["border-w-r","border-w-l"],"border-w-y":["border-w-t","border-w-b"],"border-color":["border-color-x","border-color-y","border-color-s","border-color-e","border-color-t","border-color-r","border-color-b","border-color-l"],"border-color-x":["border-color-r","border-color-l"],"border-color-y":["border-color-t","border-color-b"],translate:["translate-x","translate-y","translate-none"],"translate-none":["translate","translate-x","translate-y","translate-z"],"scroll-m":["scroll-mx","scroll-my","scroll-ms","scroll-me","scroll-mt","scroll-mr","scroll-mb","scroll-ml"],"scroll-mx":["scroll-mr","scroll-ml"],"scroll-my":["scroll-mt","scroll-mb"],"scroll-p":["scroll-px","scroll-py","scroll-ps","scroll-pe","scroll-pt","scroll-pr","scroll-pb","scroll-pl"],"scroll-px":["scroll-pr","scroll-pl"],"scroll-py":["scroll-pt","scroll-pb"],touch:["touch-x","touch-y","touch-pz"],"touch-x":["touch"],"touch-y":["touch"],"touch-pz":["touch"]},conflictingClassGroupModifiers:{"font-size":["leading"]},orderSensitiveModifiers:["*","**","after","backdrop","before","details-content","file","first-letter","first-line","marker","placeholder","selection"]}}),gt=M(pt)},334:function(t,e,n){n.d(e,{x1:function(){return x}});var i=n(148),r=n(252),o=n(262);const s={data:{type:Object,required:!0},options:{type:Object,default:()=>({})},plugins:{type:Array,default:()=>[]},datasetIdKey:{type:String,default:"label"},updateMode:{type:String,default:void 0}},a={ariaLabel:{type:String},ariaDescribedby:{type:String}},l={type:{type:String,required:!0},destroyDelay:{type:Number,default:0},...s,...a},c="2"===r.i8[0]?(t,e)=>Object.assign(t,{attrs:e}):(t,e)=>Object.assign(t,e);function u(t){return(0,o.X3)(t)?(0,o.IU)(t):t}function h(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:t;return(0,o.X3)(e)?new Proxy(t,{}):t}function d(t,e){const n=t.options;n&&e&&Object.assign(n,e)}function f(t,e){t.labels=e}function p(t,e,n){const i=[];t.datasets=e.map((e=>{const r=t.datasets.find((t=>t[n]===e[n]));return r&&e.data&&!i.includes(r)?(i.push(r),Object.assign(r,e),r):{...e}}))}function g(t,e){const n={labels:[],datasets:[]};return f(n,t.labels),p(n,t.datasets,e),n}const m=(0,r.aZ)({props:l,setup(t,e){let{expose:n,slots:s}=e;const a=(0,o.iH)(null),l=(0,o.XI)(null);n({chart:l});const c=()=>{if(!a.value)return;const{type:e,data:n,options:r,plugins:o,datasetIdKey:s}=t,c=g(n,s),u=h(c,n);l.value=new i.kL(a.value,{type:e,data:u,options:{...r},plugins:o})},m=()=>{const e=(0,o.IU)(l.value);e&&(t.destroyDelay>0?setTimeout((()=>{e.destroy(),l.value=null}),t.destroyDelay):(e.destroy(),l.value=null))},b=e=>{e.update(t.updateMode)};return(0,r.bv)(c),(0,r.Ah)(m),(0,r.YP)([()=>t.options,()=>t.data],((e,n)=>{let[i,s]=e,[a,c]=n;const h=(0,o.IU)(l.value);if(!h)return;let g=!1;if(i){const t=u(i),e=u(a);t&&t!==e&&(d(h,t),g=!0)}if(s){const e=u(s.labels),n=u(c.labels),i=u(s.datasets),r=u(c.datasets);e!==n&&(f(h.config.data,e),g=!0),i&&i!==r&&(p(h.config.data,i,t.datasetIdKey),g=!0)}g&&(0,r.Y3)((()=>{b(h)}))}),{deep:!0}),()=>(0,r.h)("canvas",{role:"img",ariaLabel:t.ariaLabel,ariaDescribedby:t.ariaDescribedby,ref:a},[(0,r.h)("p",{},[s.default?s.default():""])])}});function b(t,e){return i.kL.register(e),(0,r.aZ)({props:s,setup(e,n){let{expose:i}=n;const s=(0,o.XI)(null),a=t=>{s.value=t?.chart};return i({chart:s}),()=>(0,r.h)(m,c({ref:a},{type:t,...e}))}})}const x=b("line",i.ST)},201:function(t,e,n){n.d(e,{PO:function(){return lt},p7:function(){return re},tv:function(){return se},yj:function(){return ae}});var i=n(252),r=n(262);
/*!
* vue-router v4.5.1
* (c) 2025 Eduardo San Martin Morote
* @license MIT
*/
const o="undefined"!==typeof document;function s(t){return"object"===typeof t||"displayName"in t||"props"in t||"__vccOpts"in t}function a(t){return t.__esModule||"Module"===t[Symbol.toStringTag]||t.default&&s(t.default)}const l=Object.assign;function c(t,e){const n={};for(const i in e){const r=e[i];n[i]=h(r)?r.map(t):t(r)}return n}const u=()=>{},h=Array.isArray;const d=/#/g,f=/&/g,p=/\//g,g=/=/g,m=/\?/g,b=/\+/g,x=/%5B/g,y=/%5D/g,v=/%5E/g,w=/%60/g,k=/%7B/g,_=/%7C/g,M=/%7D/g,S=/%20/g;function T(t){return encodeURI(""+t).replace(_,"|").replace(x,"[").replace(y,"]")}function D(t){return T(t).replace(k,"{").replace(M,"}").replace(v,"^")}function C(t){return T(t).replace(b,"%2B").replace(S,"+").replace(d,"%23").replace(f,"%26").replace(w,"`").replace(k,"{").replace(M,"}").replace(v,"^")}function A(t){return C(t).replace(g,"%3D")}function O(t){return T(t).replace(d,"%23").replace(m,"%3F")}function P(t){return null==t?"":O(t).replace(p,"%2F")}function E(t){try{return decodeURIComponent(""+t)}catch(e){}return""+t}const R=/\/$/,I=t=>t.replace(R,"");function L(t,e,n="/"){let i,r={},o="",s="";const a=e.indexOf("#");let l=e.indexOf("?");return a=0&&(l=-1),l>-1&&(i=e.slice(0,l),o=e.slice(l+1,a>-1?a:e.length),r=t(o)),a>-1&&(i=i||e.slice(0,a),s=e.slice(a,e.length)),i=B(null!=i?i:e,n),{fullPath:i+(o&&"?")+o+s,path:i,query:r,hash:E(s)}}function z(t,e){const n=e.query?t(e.query):"";return e.path+(n&&"?")+n+(e.hash||"")}function N(t,e){return e&&t.toLowerCase().startsWith(e.toLowerCase())?t.slice(e.length)||"/":t}function F(t,e,n){const i=e.matched.length-1,r=n.matched.length-1;return i>-1&&i===r&&j(e.matched[i],n.matched[r])&&H(e.params,n.params)&&t(e.query)===t(n.query)&&e.hash===n.hash}function j(t,e){return(t.aliasOf||t)===(e.aliasOf||e)}function H(t,e){if(Object.keys(t).length!==Object.keys(e).length)return!1;for(const n in t)if(!W(t[n],e[n]))return!1;return!0}function W(t,e){return h(t)?$(t,e):h(e)?$(e,t):t===e}function $(t,e){return h(e)?t.length===e.length&&t.every(((t,n)=>t===e[n])):1===t.length&&t[0]===e}function B(t,e){if(t.startsWith("/"))return t;if(!t)return e;const n=e.split("/"),i=t.split("/"),r=i[i.length-1];".."!==r&&"."!==r||i.push("");let o,s,a=n.length-1;for(o=0;o1&&a--}return n.slice(0,a).join("/")+"/"+i.slice(o).join("/")}const Y={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};var V,U;(function(t){t["pop"]="pop",t["push"]="push"})(V||(V={})),function(t){t["back"]="back",t["forward"]="forward",t["unknown"]=""}(U||(U={}));function q(t){if(!t)if(o){const e=document.querySelector("base");t=e&&e.getAttribute("href")||"/",t=t.replace(/^\w+:\/\/[^\/]+/,"")}else t="/";return"/"!==t[0]&&"#"!==t[0]&&(t="/"+t),I(t)}const X=/^[^#]+#/;function G(t,e){return t.replace(X,"#")+e}function Z(t,e){const n=document.documentElement.getBoundingClientRect(),i=t.getBoundingClientRect();return{behavior:e.behavior,left:i.left-n.left-(e.left||0),top:i.top-n.top-(e.top||0)}}const Q=()=>({left:window.scrollX,top:window.scrollY});function J(t){let e;if("el"in t){const n=t.el,i="string"===typeof n&&n.startsWith("#");0;const r="string"===typeof n?i?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!r)return;e=Z(r,t)}else e=t;"scrollBehavior"in document.documentElement.style?window.scrollTo(e):window.scrollTo(null!=e.left?e.left:window.scrollX,null!=e.top?e.top:window.scrollY)}function K(t,e){const n=history.state?history.state.position-e:-1;return n+t}const tt=new Map;function et(t,e){tt.set(t,e)}function nt(t){const e=tt.get(t);return tt.delete(t),e}let it=()=>location.protocol+"//"+location.host;function rt(t,e){const{pathname:n,search:i,hash:r}=e,o=t.indexOf("#");if(o>-1){let e=r.includes(t.slice(o))?t.slice(o).length:1,n=r.slice(e);return"/"!==n[0]&&(n="/"+n),N(n,"")}const s=N(n,t);return s+i+r}function ot(t,e,n,i){let r=[],o=[],s=null;const a=({state:o})=>{const a=rt(t,location),l=n.value,c=e.value;let u=0;if(o){if(n.value=a,e.value=o,s&&s===l)return void(s=null);u=c?o.position-c.position:0}else i(a);r.forEach((t=>{t(n.value,l,{delta:u,type:V.pop,direction:u?u>0?U.forward:U.back:U.unknown})}))};function c(){s=n.value}function u(t){r.push(t);const e=()=>{const e=r.indexOf(t);e>-1&&r.splice(e,1)};return o.push(e),e}function h(){const{history:t}=window;t.state&&t.replaceState(l({},t.state,{scroll:Q()}),"")}function d(){for(const t of o)t();o=[],window.removeEventListener("popstate",a),window.removeEventListener("beforeunload",h)}return window.addEventListener("popstate",a),window.addEventListener("beforeunload",h,{passive:!0}),{pauseListeners:c,listen:u,destroy:d}}function st(t,e,n,i=!1,r=!1){return{back:t,current:e,forward:n,replaced:i,position:window.history.length,scroll:r?Q():null}}function at(t){const{history:e,location:n}=window,i={value:rt(t,n)},r={value:e.state};function o(i,o,s){const a=t.indexOf("#"),l=a>-1?(n.host&&document.querySelector("base")?t:t.slice(a))+i:it()+t+i;try{e[s?"replaceState":"pushState"](o,"",l),r.value=o}catch(c){console.error(c),n[s?"replace":"assign"](l)}}function s(t,n){const s=l({},e.state,st(r.value.back,t,r.value.forward,!0),n,{position:r.value.position});o(t,s,!0),i.value=t}function a(t,n){const s=l({},r.value,e.state,{forward:t,scroll:Q()});o(s.current,s,!0);const a=l({},st(i.value,t,null),{position:s.position+1},n);o(t,a,!1),i.value=t}return r.value||o(i.value,{back:null,current:i.value,forward:null,position:e.length-1,replaced:!0,scroll:null},!0),{location:i,state:r,push:a,replace:s}}function lt(t){t=q(t);const e=at(t),n=ot(t,e.state,e.location,e.replace);function i(t,e=!0){e||n.pauseListeners(),history.go(t)}const r=l({location:"",base:t,go:i,createHref:G.bind(null,t)},e,n);return Object.defineProperty(r,"location",{enumerable:!0,get:()=>e.location.value}),Object.defineProperty(r,"state",{enumerable:!0,get:()=>e.state.value}),r}function ct(t){return"string"===typeof t||t&&"object"===typeof t}function ut(t){return"string"===typeof t||"symbol"===typeof t}const ht=Symbol("");var dt;(function(t){t[t["aborted"]=4]="aborted",t[t["cancelled"]=8]="cancelled",t[t["duplicated"]=16]="duplicated"})(dt||(dt={}));function ft(t,e){return l(new Error,{type:t,[ht]:!0},e)}function pt(t,e){return t instanceof Error&&ht in t&&(null==e||!!(t.type&e))}const gt="[^/]+?",mt={sensitive:!1,strict:!1,start:!0,end:!0},bt=/[.+*?^${}()[\]/\\]/g;function xt(t,e){const n=l({},mt,e),i=[];let r=n.start?"^":"";const o=[];for(const l of t){const t=l.length?[]:[90];n.strict&&!l.length&&(r+="/");for(let e=0;ee.length?1===e.length&&80===e[0]?1:-1:0}function vt(t,e){let n=0;const i=t.score,r=e.score;while(n0&&e[e.length-1]<0}const kt={type:0,value:""},_t=/[a-zA-Z0-9_]/;function Mt(t){if(!t)return[[]];if("/"===t)return[[kt]];if(!t.startsWith("/"))throw new Error(`Invalid path "${t}"`);function e(t){throw new Error(`ERR (${n})/"${c}": ${t}`)}let n=0,i=n;const r=[];let o;function s(){o&&r.push(o),o=[]}let a,l=0,c="",u="";function h(){c&&(0===n?o.push({type:0,value:c}):1===n||2===n||3===n?(o.length>1&&("*"===a||"+"===a)&&e(`A repeatable param (${c}) must be alone in its segment. eg: '/:ids+.`),o.push({type:1,value:c,regexp:u,repeatable:"*"===a||"+"===a,optional:"*"===a||"?"===a})):e("Invalid state to consume buffer"),c="")}function d(){c+=a}while(l{s(p)}:u}function s(t){if(ut(t)){const e=i.get(t);e&&(i.delete(t),n.splice(n.indexOf(e),1),e.children.forEach(s),e.alias.forEach(s))}else{const e=n.indexOf(t);e>-1&&(n.splice(e,1),t.record.name&&i.delete(t.record.name),t.children.forEach(s),t.alias.forEach(s))}}function a(){return n}function c(t){const e=Rt(t,n);n.splice(e,0,t),t.record.name&&!Ot(t)&&i.set(t.record.name,t)}function h(t,e){let r,o,s,a={};if("name"in t&&t.name){if(r=i.get(t.name),!r)throw ft(1,{location:t});0,s=r.record.name,a=l(Dt(e.params,r.keys.filter((t=>!t.optional)).concat(r.parent?r.parent.keys.filter((t=>t.optional)):[]).map((t=>t.name))),t.params&&Dt(t.params,r.keys.map((t=>t.name)))),o=r.stringify(a)}else if(null!=t.path)o=t.path,r=n.find((t=>t.re.test(o))),r&&(a=r.parse(o),s=r.record.name);else{if(r=e.name?i.get(e.name):n.find((t=>t.re.test(e.path))),!r)throw ft(1,{location:t,currentLocation:e});s=r.record.name,a=l({},e.params,t.params),o=r.stringify(a)}const c=[];let u=r;while(u)c.unshift(u.record),u=u.parent;return{name:s,path:o,params:a,matched:c,meta:Pt(c)}}function d(){n.length=0,i.clear()}return e=Et({strict:!1,end:!0,sensitive:!1},e),t.forEach((t=>o(t))),{addRoute:o,resolve:h,removeRoute:s,clearRoutes:d,getRoutes:a,getRecordMatcher:r}}function Dt(t,e){const n={};for(const i of e)i in t&&(n[i]=t[i]);return n}function Ct(t){const e={path:t.path,redirect:t.redirect,name:t.name,meta:t.meta||{},aliasOf:t.aliasOf,beforeEnter:t.beforeEnter,props:At(t),children:t.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in t?t.components||null:t.component&&{default:t.component}};return Object.defineProperty(e,"mods",{value:{}}),e}function At(t){const e={},n=t.props||!1;if("component"in t)e.default=n;else for(const i in t.components)e[i]="object"===typeof n?n[i]:n;return e}function Ot(t){while(t){if(t.record.aliasOf)return!0;t=t.parent}return!1}function Pt(t){return t.reduce(((t,e)=>l(t,e.meta)),{})}function Et(t,e){const n={};for(const i in t)n[i]=i in e?e[i]:t[i];return n}function Rt(t,e){let n=0,i=e.length;while(n!==i){const r=n+i>>1,o=vt(t,e[r]);o<0?i=r:n=r+1}const r=It(t);return r&&(i=e.lastIndexOf(r,i-1)),i}function It(t){let e=t;while(e=e.parent)if(Lt(e)&&0===vt(t,e))return e}function Lt({record:t}){return!!(t.name||t.components&&Object.keys(t.components).length||t.redirect)}function zt(t){const e={};if(""===t||"?"===t)return e;const n="?"===t[0],i=(n?t.slice(1):t).split("&");for(let r=0;rt&&C(t))):[i&&C(i)];r.forEach((t=>{void 0!==t&&(e+=(e.length?"&":"")+n,null!=t&&(e+="="+t))}))}return e}function Ft(t){const e={};for(const n in t){const i=t[n];void 0!==i&&(e[n]=h(i)?i.map((t=>null==t?null:""+t)):null==i?i:""+i)}return e}const jt=Symbol(""),Ht=Symbol(""),Wt=Symbol(""),$t=Symbol(""),Bt=Symbol("");function Yt(){let t=[];function e(e){return t.push(e),()=>{const n=t.indexOf(e);n>-1&&t.splice(n,1)}}function n(){t=[]}return{add:e,list:()=>t.slice(),reset:n}}function Vt(t,e,n,i,r,o=(t=>t())){const s=i&&(i.enterCallbacks[r]=i.enterCallbacks[r]||[]);return()=>new Promise(((a,l)=>{const c=t=>{!1===t?l(ft(4,{from:n,to:e})):t instanceof Error?l(t):ct(t)?l(ft(2,{from:e,to:t})):(s&&i.enterCallbacks[r]===s&&"function"===typeof t&&s.push(t),a())},u=o((()=>t.call(i&&i.instances[r],e,n,c)));let h=Promise.resolve(u);t.length<3&&(h=h.then(c)),h.catch((t=>l(t)))}))}function Ut(t,e,n,i,r=(t=>t())){const o=[];for(const l of t){0;for(const t in l.components){let c=l.components[t];if("beforeRouteEnter"===e||l.instances[t])if(s(c)){const s=c.__vccOpts||c,a=s[e];a&&o.push(Vt(a,n,i,l,t,r))}else{let s=c();0,o.push((()=>s.then((o=>{if(!o)throw new Error(`Couldn't resolve component "${t}" at "${l.path}"`);const s=a(o)?o.default:o;l.mods[t]=o,l.components[t]=s;const c=s.__vccOpts||s,u=c[e];return u&&Vt(u,n,i,l,t,r)()}))))}}}return o}function qt(t){const e=(0,i.f3)(Wt),n=(0,i.f3)($t);const o=(0,i.Fl)((()=>{const n=(0,r.SU)(t.to);return e.resolve(n)})),s=(0,i.Fl)((()=>{const{matched:t}=o.value,{length:e}=t,i=t[e-1],r=n.matched;if(!i||!r.length)return-1;const s=r.findIndex(j.bind(null,i));if(s>-1)return s;const a=Kt(t[e-2]);return e>1&&Kt(i)===a&&r[r.length-1].path!==a?r.findIndex(j.bind(null,t[e-2])):s})),a=(0,i.Fl)((()=>s.value>-1&&Jt(n.params,o.value.params))),l=(0,i.Fl)((()=>s.value>-1&&s.value===n.matched.length-1&&H(n.params,o.value.params)));function c(n={}){if(Qt(n)){const n=e[(0,r.SU)(t.replace)?"replace":"push"]((0,r.SU)(t.to)).catch(u);return t.viewTransition&&"undefined"!==typeof document&&"startViewTransition"in document&&document.startViewTransition((()=>n)),n}return Promise.resolve()}return{route:o,href:(0,i.Fl)((()=>o.value.href)),isActive:a,isExactActive:l,navigate:c}}function Xt(t){return 1===t.length?t[0]:t}const Gt=(0,i.aZ)({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:qt,setup(t,{slots:e}){const n=(0,r.qj)(qt(t)),{options:o}=(0,i.f3)(Wt),s=(0,i.Fl)((()=>({[te(t.activeClass,o.linkActiveClass,"router-link-active")]:n.isActive,[te(t.exactActiveClass,o.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive})));return()=>{const r=e.default&&Xt(e.default(n));return t.custom?r:(0,i.h)("a",{"aria-current":n.isExactActive?t.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:s.value},r)}}}),Zt=Gt;function Qt(t){if(!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)&&!t.defaultPrevented&&(void 0===t.button||0===t.button)){if(t.currentTarget&&t.currentTarget.getAttribute){const e=t.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(e))return}return t.preventDefault&&t.preventDefault(),!0}}function Jt(t,e){for(const n in e){const i=e[n],r=t[n];if("string"===typeof i){if(i!==r)return!1}else if(!h(r)||r.length!==i.length||i.some(((t,e)=>t!==r[e])))return!1}return!0}function Kt(t){return t?t.aliasOf?t.aliasOf.path:t.path:""}const te=(t,e,n)=>null!=t?t:null!=e?e:n,ee=(0,i.aZ)({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(t,{attrs:e,slots:n}){const o=(0,i.f3)(Bt),s=(0,i.Fl)((()=>t.route||o.value)),a=(0,i.f3)(Ht,0),c=(0,i.Fl)((()=>{let t=(0,r.SU)(a);const{matched:e}=s.value;let n;while((n=e[t])&&!n.components)t++;return t})),u=(0,i.Fl)((()=>s.value.matched[c.value]));(0,i.JJ)(Ht,(0,i.Fl)((()=>c.value+1))),(0,i.JJ)(jt,u),(0,i.JJ)(Bt,s);const h=(0,r.iH)();return(0,i.YP)((()=>[h.value,u.value,t.name]),(([t,e,n],[i,r,o])=>{e&&(e.instances[n]=t,r&&r!==e&&t&&t===i&&(e.leaveGuards.size||(e.leaveGuards=r.leaveGuards),e.updateGuards.size||(e.updateGuards=r.updateGuards))),!t||!e||r&&j(e,r)&&i||(e.enterCallbacks[n]||[]).forEach((e=>e(t)))}),{flush:"post"}),()=>{const r=s.value,o=t.name,a=u.value,c=a&&a.components[o];if(!c)return ne(n.default,{Component:c,route:r});const d=a.props[o],f=d?!0===d?r.params:"function"===typeof d?d(r):d:null,p=t=>{t.component.isUnmounted&&(a.instances[o]=null)},g=(0,i.h)(c,l({},f,e,{onVnodeUnmounted:p,ref:h}));return ne(n.default,{Component:g,route:r})||g}}});function ne(t,e){if(!t)return null;const n=t(e);return 1===n.length?n[0]:n}const ie=ee;function re(t){const e=Tt(t.routes,t),n=t.parseQuery||zt,s=t.stringifyQuery||Nt,a=t.history;const d=Yt(),f=Yt(),p=Yt(),g=(0,r.XI)(Y);let m=Y;o&&t.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const b=c.bind(null,(t=>""+t)),x=c.bind(null,P),y=c.bind(null,E);function v(t,n){let i,r;return ut(t)?(i=e.getRecordMatcher(t),r=n):r=t,e.addRoute(r,i)}function w(t){const n=e.getRecordMatcher(t);n&&e.removeRoute(n)}function k(){return e.getRoutes().map((t=>t.record))}function _(t){return!!e.getRecordMatcher(t)}function M(t,i){if(i=l({},i||g.value),"string"===typeof t){const r=L(n,t,i.path),o=e.resolve({path:r.path},i),s=a.createHref(r.fullPath);return l(r,o,{params:y(o.params),hash:E(r.hash),redirectedFrom:void 0,href:s})}let r;if(null!=t.path)r=l({},t,{path:L(n,t.path,i.path).path});else{const e=l({},t.params);for(const t in e)null==e[t]&&delete e[t];r=l({},t,{params:x(e)}),i.params=x(i.params)}const o=e.resolve(r,i),c=t.hash||"";o.params=b(y(o.params));const u=z(s,l({},t,{hash:D(c),path:o.path})),h=a.createHref(u);return l({fullPath:u,hash:c,query:s===Nt?Ft(t.query):t.query||{}},o,{redirectedFrom:void 0,href:h})}function S(t){return"string"===typeof t?L(n,t,g.value.path):l({},t)}function T(t,e){if(m!==t)return ft(8,{from:e,to:t})}function C(t){return R(t)}function A(t){return C(l(S(t),{replace:!0}))}function O(t){const e=t.matched[t.matched.length-1];if(e&&e.redirect){const{redirect:n}=e;let i="function"===typeof n?n(t):n;return"string"===typeof i&&(i=i.includes("?")||i.includes("#")?i=S(i):{path:i},i.params={}),l({query:t.query,hash:t.hash,params:null!=i.path?{}:t.params},i)}}function R(t,e){const n=m=M(t),i=g.value,r=t.state,o=t.force,a=!0===t.replace,c=O(n);if(c)return R(l(S(c),{state:"object"===typeof c?l({},r,c.state):r,force:o,replace:a}),e||n);const u=n;let h;return u.redirectedFrom=e,!o&&F(s,i,n)&&(h=ft(16,{to:u,from:i}),it(i,i,!0,!1)),(h?Promise.resolve(h):j(u,i)).catch((t=>pt(t)?pt(t,2)?t:tt(t):G(t,u,i))).then((t=>{if(t){if(pt(t,2))return R(l({replace:a},S(t.to),{state:"object"===typeof t.to?l({},r,t.to.state):r,force:o}),e||u)}else t=W(u,i,!0,a,r);return H(u,i,t),t}))}function I(t,e){const n=T(t,e);return n?Promise.reject(n):Promise.resolve()}function N(t){const e=st.values().next().value;return e&&"function"===typeof e.runWithContext?e.runWithContext(t):t()}function j(t,e){let n;const[i,r,o]=oe(t,e);n=Ut(i.reverse(),"beforeRouteLeave",t,e);for(const a of i)a.leaveGuards.forEach((i=>{n.push(Vt(i,t,e))}));const s=I.bind(null,t,e);return n.push(s),lt(n).then((()=>{n=[];for(const i of d.list())n.push(Vt(i,t,e));return n.push(s),lt(n)})).then((()=>{n=Ut(r,"beforeRouteUpdate",t,e);for(const i of r)i.updateGuards.forEach((i=>{n.push(Vt(i,t,e))}));return n.push(s),lt(n)})).then((()=>{n=[];for(const i of o)if(i.beforeEnter)if(h(i.beforeEnter))for(const r of i.beforeEnter)n.push(Vt(r,t,e));else n.push(Vt(i.beforeEnter,t,e));return n.push(s),lt(n)})).then((()=>(t.matched.forEach((t=>t.enterCallbacks={})),n=Ut(o,"beforeRouteEnter",t,e,N),n.push(s),lt(n)))).then((()=>{n=[];for(const i of f.list())n.push(Vt(i,t,e));return n.push(s),lt(n)})).catch((t=>pt(t,8)?t:Promise.reject(t)))}function H(t,e,n){p.list().forEach((i=>N((()=>i(t,e,n)))))}function W(t,e,n,i,r){const s=T(t,e);if(s)return s;const c=e===Y,u=o?history.state:{};n&&(i||c?a.replace(t.fullPath,l({scroll:c&&u&&u.scroll},r)):a.push(t.fullPath,r)),g.value=t,it(t,e,n,c),tt()}let $;function B(){$||($=a.listen(((t,e,n)=>{if(!at.listening)return;const i=M(t),r=O(i);if(r)return void R(l(r,{replace:!0,force:!0}),i).catch(u);m=i;const s=g.value;o&&et(K(s.fullPath,n.delta),Q()),j(i,s).catch((t=>pt(t,12)?t:pt(t,2)?(R(l(S(t.to),{force:!0}),i).then((t=>{pt(t,20)&&!n.delta&&n.type===V.pop&&a.go(-1,!1)})).catch(u),Promise.reject()):(n.delta&&a.go(-n.delta,!1),G(t,i,s)))).then((t=>{t=t||W(i,s,!1),t&&(n.delta&&!pt(t,8)?a.go(-n.delta,!1):n.type===V.pop&&pt(t,20)&&a.go(-1,!1)),H(i,s,t)})).catch(u)})))}let U,q=Yt(),X=Yt();function G(t,e,n){tt(t);const i=X.list();return i.length?i.forEach((i=>i(t,e,n))):console.error(t),Promise.reject(t)}function Z(){return U&&g.value!==Y?Promise.resolve():new Promise(((t,e)=>{q.add([t,e])}))}function tt(t){return U||(U=!t,B(),q.list().forEach((([e,n])=>t?n(t):e())),q.reset()),t}function it(e,n,r,s){const{scrollBehavior:a}=t;if(!o||!a)return Promise.resolve();const l=!r&&nt(K(e.fullPath,0))||(s||!r)&&history.state&&history.state.scroll||null;return(0,i.Y3)().then((()=>a(e,n,l))).then((t=>t&&J(t))).catch((t=>G(t,e,n)))}const rt=t=>a.go(t);let ot;const st=new Set,at={currentRoute:g,listening:!0,addRoute:v,removeRoute:w,clearRoutes:e.clearRoutes,hasRoute:_,getRoutes:k,resolve:M,options:t,push:C,replace:A,go:rt,back:()=>rt(-1),forward:()=>rt(1),beforeEach:d.add,beforeResolve:f.add,afterEach:p.add,onError:X.add,isReady:Z,install(t){const e=this;t.component("RouterLink",Zt),t.component("RouterView",ie),t.config.globalProperties.$router=e,Object.defineProperty(t.config.globalProperties,"$route",{enumerable:!0,get:()=>(0,r.SU)(g)}),o&&!ot&&g.value===Y&&(ot=!0,C(a.location).catch((t=>{0})));const n={};for(const r in Y)Object.defineProperty(n,r,{get:()=>g.value[r],enumerable:!0});t.provide(Wt,e),t.provide($t,(0,r.Um)(n)),t.provide(Bt,g);const i=t.unmount;st.add(t),t.unmount=function(){st.delete(t),st.size<1&&(m=Y,$&&$(),$=null,g.value=Y,ot=!1,U=!1),i()}}};function lt(t){return t.reduce(((t,e)=>t.then((()=>N(e)))),Promise.resolve())}return at}function oe(t,e){const n=[],i=[],r=[],o=Math.max(e.matched.length,t.matched.length);for(let s=0;sj(t,o)))?i.push(o):n.push(o));const a=t.matched[s];a&&(e.matched.find((t=>j(t,a)))||r.push(a))}return[n,i,r]}function se(){return(0,i.f3)(Wt)}function ae(t){return(0,i.f3)($t)}}}]);
================================================
FILE: web/static/manifest.json
================================================
{
"id": "gatus",
"name": "Gatus",
"short_name": "Gatus",
"description": "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue",
"lang": "en",
"scope": "/",
"start_url": "/",
"theme_color": "#f7f9fb",
"background_color": "#f7f9fb",
"display": "standalone",
"icons": [
{
"src": "/logo-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/logo-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
================================================
FILE: web/static.go
================================================
package static
import "embed"
var (
//go:embed static
FileSystem embed.FS
)
const (
RootPath = "static"
IndexPath = RootPath + "/index.html"
)
================================================
FILE: web/static_test.go
================================================
package static
import (
"io/fs"
"strings"
"testing"
)
func TestEmbed(t *testing.T) {
scenarios := []struct {
path string
shouldExist bool
expectedContainString string
}{
{
path: "index.html",
shouldExist: true,
expectedContainString: "",
},
{
path: "favicon.ico",
shouldExist: true,
expectedContainString: "", // not checking because it's an image
},
{
path: "img/logo.svg",
shouldExist: true,
expectedContainString: "",
},
{
path: "css/app.css",
shouldExist: true,
expectedContainString: "background-color",
},
{
path: "js/app.js",
shouldExist: true,
expectedContainString: "function",
},
{
path: "js/chunk-vendors.js",
shouldExist: true,
expectedContainString: "function",
},
{
path: "file-that-does-not-exist.html",
shouldExist: false,
},
}
staticFileSystem, err := fs.Sub(FileSystem, RootPath)
if err != nil {
t.Fatal(err)
}
for _, scenario := range scenarios {
t.Run(scenario.path, func(t *testing.T) {
content, err := fs.ReadFile(staticFileSystem, scenario.path)
if !scenario.shouldExist {
if err == nil {
t.Errorf("%s should not have existed", scenario.path)
}
} else {
if err != nil {
t.Errorf("opening %s should not have returned an error, got %s", scenario.path, err.Error())
}
if len(content) == 0 {
t.Errorf("%s should have existed in the static FileSystem, but was empty", scenario.path)
}
if !strings.Contains(string(content), scenario.expectedContainString) {
t.Errorf("%s should have contained %s, but did not", scenario.path, scenario.expectedContainString)
}
}
})
}
}