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`. ![Gatus Grafana dashboard](../../.github/assets/grafana-dashboard.png) ## 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 ================================================ [![Gatus](.github/assets/logo-with-dark-text.png)](https://gatus.io) ![test](https://github.com/TwiN/gatus/actions/workflows/test.yml/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus) [![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus) [![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus) [![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus) [![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN) Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS 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). ![Gatus dashboard](.github/assets/dashboard-dark.jpg) 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)**: ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg) - **Dark mode** ![Gatus dashboard conditions](.github/assets/dashboard-conditions.jpg) ## 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: ![Simple example](.github/assets/example.jpg) 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: ![Gatus past announcements section](.github/assets/past-announcements.jpg) ### 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" ``` ![Gitea alert](.github/assets/gitea-alerts.png) #### 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" ``` ![GitHub alert](.github/assets/github-alerts.png) #### 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" ``` ![GitLab alert](.github/assets/gitlab-alerts.png) #### 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: ![Gotify notifications](.github/assets/gotify-alerts.png) #### 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: ![Mattermost notifications](.github/assets/mattermost-alerts.png) #### 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: ![Slack notifications](.github/assets/slack-alerts.png) #### 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: ![Teams notifications](.github/assets/teams-alerts.png) #### Configuring Teams Workflow alerts > [!NOTE] > This alert is compatible with Workflows for Microsoft Teams. To setup the workflow and get the webhook URL, follow the [Microsoft Documentation](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498). | Parameter | Description | Default | |:---------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------| | `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` | | `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` | | `alerting.teams-workflows.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: ![Teams Workflow notifications](.github/assets/teams-workflows-alerts.png) #### 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: ![Telegram notifications](.github/assets/telegram-alerts.png) #### 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: ![Gatus Endpoint Groups](.github/assets/endpoint-groups.jpg) ### 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 ![Uptime 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/1h/badge.svg) ![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg) ![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg) ![Uptime 30d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/30d/badge.svg) 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: ``` ![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg) ``` If you'd like to see a visual example of each badge available, you can simply navigate to the endpoint's detail page. #### Health ![Health](https://status.twin.sh/api/v1/endpoints/core_blog-external/health/badge.svg) 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) ![Health](https://img.shields.io/endpoint?url=https%3A%2F%2Fstatus.twin.sh%2Fapi%2Fv1%2Fendpoints%2Fcore_blog-external%2Fhealth%2Fbadge.shields) 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 ![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg) ![Response time 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/7d/badge.svg) ![Response time 30d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/30d/badge.svg) 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) ![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/chart.svg) ![Response time 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/7d/chart.svg) ![Response time 30d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/30d/chart.svg) 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 ![Gatus diagram](.github/assets/gatus-diagram.jpg) ================================================ 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 = "\n
Condition 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. ![PagerDuty Integration Key](https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/pagerduty-integration-key.png) ## 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 }}
================================================ 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 ================================================ ================================================ FILE: web/app/src/components/AnnouncementBanner.vue ================================================ ================================================ FILE: web/app/src/components/EndpointCard.vue ================================================ ================================================ FILE: web/app/src/components/FlowStep.vue ================================================ ================================================ FILE: web/app/src/components/Loading.vue ================================================ ================================================ FILE: web/app/src/components/Pagination.vue ================================================ ================================================ FILE: web/app/src/components/PastAnnouncements.vue ================================================ ================================================ FILE: web/app/src/components/ResponseTimeChart.vue ================================================ ================================================ FILE: web/app/src/components/SearchBar.vue ================================================ ================================================ FILE: web/app/src/components/SequentialFlowDiagram.vue ================================================ ================================================ FILE: web/app/src/components/Settings.vue ================================================ ================================================ FILE: web/app/src/components/Social.vue ================================================ ================================================ FILE: web/app/src/components/StatusBadge.vue ================================================ ================================================ FILE: web/app/src/components/StepDetailsModal.vue ================================================ ================================================ FILE: web/app/src/components/SuiteCard.vue ================================================ ================================================ 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 ================================================ ================================================ 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 ================================================ ================================================ FILE: web/app/src/views/Home.vue ================================================ ================================================ FILE: web/app/src/views/SuiteDetails.vue ================================================ ================================================ 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 }}
================================================ 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:/^$/,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]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\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",")|<(?: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",")|<(?: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",")|<(?:script|pre|style|textarea|!--)").replace("tag",M).getRegex()},E={...A,html:a("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\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:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\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-]*(?: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+"\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\n"+e+"\n"+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`}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='
    ",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=`${n}{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) } } }) } }