[
  {
    "path": ".dockerignore",
    "content": ".examples\nDockerfile\n.github\n.idea\n.git\nweb/app\n*.db\ntestdata"
  },
  {
    "path": ".examples/docker-compose/compose.yaml",
    "content": "services:\n  gatus:\n    image: twinproduction/gatus:latest\n    ports:\n      - 8080:8080\n    volumes:\n      - ./config:/config\n"
  },
  {
    "path": ".examples/docker-compose-grafana-prometheus/README.md",
    "content": "## Usage\nGatus exposes Prometheus metrics at `/metrics` if the `metrics` configuration option is set to `true`.\n\nTo run this example, all you need to do is execute the following command:\n```console\ndocker-compose up\n```\nOnce you've done the above, you should be able to access the Grafana dashboard at `http://localhost:3000`.\n\n![Gatus Grafana dashboard](../../.github/assets/grafana-dashboard.png)\n\n\n## Queries\nBy default, this example has a Grafana dashboard with some panels, but for the sake of verbosity, you'll find\na list of simple queries below. Those make use of the `key` parameter, which is a concatenation of the endpoint's\ngroup and name.\n\n### Success rate\n```\nsum(rate(gatus_results_total{success=\"true\"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)\n```\n\n### Response time\n```\ngatus_results_duration_seconds\n```\n\n### Total results per minute\n```\nsum(rate(gatus_results_total[5m])*60) by (key)\n```\n\n### Total successful results per minute\n```\nsum(rate(gatus_results_total{success=\"true\"}[5m])*60) by (key)\n```\n\n### Total unsuccessful results per minute\n```\nsum(rate(gatus_results_total{success=\"false\"}[5m])*60) by (key)\n```\n"
  },
  {
    "path": ".examples/docker-compose-grafana-prometheus/compose.yaml",
    "content": "services:\n  gatus:\n    container_name: gatus\n    image: twinproduction/gatus\n    restart: always\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - ./config:/config\n    networks:\n      - metrics\n\n  prometheus:\n    container_name: prometheus\n    image: prom/prometheus:v3.5.0\n    restart: always\n    command: --config.file=/etc/prometheus/prometheus.yml\n    ports:\n      - \"9090:9090\"\n    volumes:\n      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml\n    networks:\n      - metrics\n\n  grafana:\n    container_name: grafana\n    image: grafana/grafana:12.1.0\n    restart: always\n    environment:\n      GF_SECURITY_ADMIN_PASSWORD: secret\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - ./grafana/grafana.ini/:/etc/grafana/grafana.ini:ro\n      - ./grafana/provisioning/:/etc/grafana/provisioning/:ro\n    networks:\n      - metrics\n\nnetworks:\n  metrics:\n    driver: bridge\n"
  },
  {
    "path": ".examples/docker-compose-grafana-prometheus/grafana/grafana.ini",
    "content": "[paths]\n\n[server]\n\n[database]\n\n[session]\n\n[dataproxy]\n\n[analytics]\nreporting_enabled = false\n\n[security]\n\n[snapshots]\n\n[dashboards]\n\n[users]\nallow_sign_up = false\ndefault_theme = light\n\n[auth]\n\n[auth.anonymous]\nenabled = true\norg_name = Main Org.\norg_role = Admin\n\n[auth.github]\n\n[auth.google]\n\n[auth.generic_oauth]\n\n[auth.grafana_com]\n\n[auth.proxy]\n\n[auth.basic]\n\n[auth.ldap]\n\n[smtp]\n\n[emails]\n\n[log]\nmode = console\n\n[log.console]\n\n[log.file]\n\n[log.syslog]\n\n[alerting]\n\n[explore]\n\n[metrics]\nenabled = true\n\n[metrics.graphite]\n\n[tracing.jaeger]\n\n[grafana_com]\n\n[external_image_storage]\n\n[external_image_storage.s3]\n\n[external_image_storage.webdav]\n\n[external_image_storage.gcs]\n\n[external_image_storage.azure_blob]\n\n[external_image_storage.local]\n\n[rendering]\n\n[enterprise]\n"
  },
  {
    "path": ".examples/docker-compose-grafana-prometheus/grafana/provisioning/dashboards/dashboard.yml",
    "content": "apiVersion: 1\n\nproviders:\n  - name: 'Prometheus'\n    orgId: 1\n    folder: ''\n    type: file\n    disableDeletion: false\n    editable: true\n    options:\n      path: /etc/grafana/provisioning/dashboards"
  },
  {
    "path": ".examples/docker-compose-grafana-prometheus/grafana/provisioning/dashboards/gatus.json",
    "content": "{\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"grafana\",\n          \"uid\": \"-- Grafana --\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"description\": \"Monitoring dashboard for service uptime monitoring using Gatus metrics\",\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": 41,\n  \"links\": [],\n  \"panels\": [\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"$datasource\"\n      },\n      \"description\": \"Services with certificate expiration warnings\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"noValue\": \"0\",\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"yellow\",\n                \"value\": 30\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 7\n              }\n            ]\n          },\n          \"unit\": \"short\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 4,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 8,\n      \"options\": {\n        \"colorMode\": \"background\",\n        \"graphMode\": \"area\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"percentChangeColorMode\": \"standard\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showPercentChange\": false,\n        \"textMode\": \"auto\",\n        \"wideLayout\": true\n      },\n      \"pluginVersion\": \"12.1.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"count(gatus_results_certificate_expiration_seconds < 2592000)\",\n          \"hide\": false,\n          \"legendFormat\": \"Expiring < 30 days\",\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"count(gatus_results_certificate_expiration_seconds < 604800)\",\n          \"legendFormat\": \"Expiring < 7 days\",\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Certificate Warnings\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"$datasource\"\n      },\n      \"description\": \"Overall service availability\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"max\": 100,\n          \"min\": 0,\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"yellow\",\n                \"value\": 95\n              },\n              {\n                \"color\": \"green\",\n                \"value\": 99\n              }\n            ]\n          },\n          \"unit\": \"percent\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 4,\n        \"x\": 4,\n        \"y\": 0\n      },\n      \"id\": 9,\n      \"options\": {\n        \"colorMode\": \"background\",\n        \"graphMode\": \"area\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"percentChangeColorMode\": \"standard\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showPercentChange\": false,\n        \"textMode\": \"auto\",\n        \"wideLayout\": true\n      },\n      \"pluginVersion\": \"12.1.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"(sum(gatus_results_endpoint_success) / count(gatus_results_endpoint_success)) * 100\",\n          \"legendFormat\": \"Overall Availability\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Overall Availability\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"$datasource\"\n      },\n      \"description\": \"Number of services being monitored\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              }\n            ]\n          },\n          \"unit\": \"short\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 4,\n        \"x\": 8,\n        \"y\": 0\n      },\n      \"id\": 10,\n      \"options\": {\n        \"colorMode\": \"background\",\n        \"graphMode\": \"area\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"percentChangeColorMode\": \"standard\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showPercentChange\": false,\n        \"textMode\": \"auto\",\n        \"wideLayout\": true\n      },\n      \"pluginVersion\": \"12.1.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"count(gatus_results_endpoint_success)\",\n          \"legendFormat\": \"Total Services\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Monitored Services\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"$datasource\"\n      },\n      \"description\": \"Overview of service health status across all groups\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"vis\": false,\n              \"viz\": false\n            }\n          },\n          \"mappings\": [],\n          \"unit\": \"short\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 0\n      },\n      \"id\": 1,\n      \"options\": {\n        \"legend\": {\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true,\n          \"values\": []\n        },\n        \"pieType\": \"pie\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.1.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"sum(gatus_results_endpoint_success) by (group)\",\n          \"legendFormat\": \"{{group}} - UP\",\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"sum(1 - gatus_results_endpoint_success) by (group)\",\n          \"legendFormat\": \"{{group}} - DOWN\",\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Service Health by Group\",\n      \"type\": \"piechart\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"$datasource\"\n      },\n      \"description\": \"Domain expiration times for all domains\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"#EAB839\",\n                \"value\": 172800\n              },\n              {\n                \"color\": \"green\",\n                \"value\": 604800\n              }\n            ]\n          },\n          \"unit\": \"dtdurations\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Time Until Expiry\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.cellOptions\",\n                \"value\": {\n                  \"applyToRow\": false,\n                  \"type\": \"color-background\"\n                }\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"s\"\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 8\n      },\n      \"id\": 3,\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true,\n        \"sortBy\": []\n      },\n      \"pluginVersion\": \"12.1.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"gatus_results_domain_expiration_seconds\",\n          \"format\": \"table\",\n          \"instant\": true,\n          \"legendFormat\": \"__auto\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Domain Expiration\",\n      \"transformations\": [\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Time\": true,\n              \"Value\": false,\n              \"__name__\": true,\n              \"app_kubernetes_io_instance\": true,\n              \"app_kubernetes_io_managed_by\": true,\n              \"app_kubernetes_io_name\": true,\n              \"app_kubernetes_io_service\": true,\n              \"helm_sh_chart\": true,\n              \"instance\": true,\n              \"job\": true,\n              \"key\": true,\n              \"type\": true\n            },\n            \"includeByName\": {},\n            \"indexByName\": {\n              \"Value\": 2,\n              \"group\": 0,\n              \"name\": 1\n            },\n            \"renameByName\": {\n              \"Value\": \"Time Until Expiry\",\n              \"group\": \"Group\",\n              \"name\": \"Service\"\n            }\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"$datasource\"\n      },\n      \"description\": \"SSL certificate expiration times for all services\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"#EAB839\",\n                \"value\": 172800\n              },\n              {\n                \"color\": \"green\",\n                \"value\": 604800\n              }\n            ]\n          },\n          \"unit\": \"dtdurations\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Time Until Expiry\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.cellOptions\",\n                \"value\": {\n                  \"applyToRow\": false,\n                  \"type\": \"color-background\"\n                }\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"s\"\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 8\n      },\n      \"id\": 11,\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true,\n        \"sortBy\": []\n      },\n      \"pluginVersion\": \"12.1.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"gatus_results_certificate_expiration_seconds\",\n          \"format\": \"table\",\n          \"instant\": true,\n          \"legendFormat\": \"__auto\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"SSL Certificate Expiration\",\n      \"transformations\": [\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Time\": true,\n              \"Value\": false,\n              \"__name__\": true,\n              \"app_kubernetes_io_instance\": true,\n              \"app_kubernetes_io_managed_by\": true,\n              \"app_kubernetes_io_name\": true,\n              \"app_kubernetes_io_service\": true,\n              \"helm_sh_chart\": true,\n              \"instance\": true,\n              \"job\": true,\n              \"key\": true,\n              \"type\": true\n            },\n            \"includeByName\": {},\n            \"indexByName\": {\n              \"Value\": 2,\n              \"group\": 0,\n              \"name\": 1\n            },\n            \"renameByName\": {\n              \"Value\": \"Time Until Expiry\",\n              \"group\": \"Group\",\n              \"name\": \"Service\"\n            }\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"$datasource\"\n      },\n      \"description\": \"Current status distribution across all services\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"vis\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"short\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 16\n      },\n      \"id\": 5,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.1.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"sum(gatus_results_endpoint_success)\",\n          \"legendFormat\": \"Services UP\",\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"sum(1 - gatus_results_endpoint_success)\",\n          \"legendFormat\": \"Services DOWN\",\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Service Status Distribution\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"$datasource\"\n      },\n      \"description\": \"Current status of all monitored services\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"inspect\": false\n          },\n          \"mappings\": [\n            {\n              \"options\": {\n                \"0\": {\n                  \"color\": \"red\",\n                  \"index\": 0,\n                  \"text\": \"DOWN\"\n                },\n                \"1\": {\n                  \"color\": \"green\",\n                  \"index\": 1,\n                  \"text\": \"UP\"\n                }\n              },\n              \"type\": \"value\"\n            }\n          ],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"green\",\n                \"value\": 1\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Status\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.cellOptions\",\n                \"value\": {\n                  \"type\": \"color-background\"\n                }\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Response Time\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"unit\",\n                \"value\": \"s\"\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 16\n      },\n      \"id\": 2,\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true,\n        \"sortBy\": [\n          {\n            \"desc\": false,\n            \"displayName\": \"Status\"\n          }\n        ]\n      },\n      \"pluginVersion\": \"12.1.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"gatus_results_endpoint_success\",\n          \"format\": \"table\",\n          \"instant\": true,\n          \"legendFormat\": \"__auto\",\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"gatus_results_duration_seconds\",\n          \"format\": \"table\",\n          \"instant\": true,\n          \"legendFormat\": \"__auto\",\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Service Status Overview\",\n      \"transformations\": [\n        {\n          \"id\": \"joinByField\",\n          \"options\": {\n            \"byField\": \"name\",\n            \"mode\": \"outer\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Time\": true,\n              \"__name__\": true,\n              \"app_kubernetes_io_instance\": true,\n              \"app_kubernetes_io_managed_by\": true,\n              \"app_kubernetes_io_name\": true,\n              \"app_kubernetes_io_service\": true,\n              \"group 2\": true,\n              \"helm_sh_chart\": true,\n              \"instance\": true,\n              \"job\": true,\n              \"key\": true,\n              \"type\": true\n            },\n            \"includeByName\": {},\n            \"indexByName\": {\n              \"Time 1\": 4,\n              \"Time 2\": 15,\n              \"Value #A\": 2,\n              \"Value #B\": 3,\n              \"__name__ 1\": 5,\n              \"__name__ 2\": 16,\n              \"app_kubernetes_io_instance 1\": 6,\n              \"app_kubernetes_io_instance 2\": 17,\n              \"app_kubernetes_io_managed_by 1\": 7,\n              \"app_kubernetes_io_managed_by 2\": 18,\n              \"app_kubernetes_io_name 1\": 8,\n              \"app_kubernetes_io_name 2\": 19,\n              \"app_kubernetes_io_service 1\": 9,\n              \"app_kubernetes_io_service 2\": 20,\n              \"group 1\": 0,\n              \"group 2\": 21,\n              \"helm_sh_chart 1\": 10,\n              \"helm_sh_chart 2\": 22,\n              \"instance 1\": 11,\n              \"instance 2\": 23,\n              \"job 1\": 12,\n              \"job 2\": 24,\n              \"key 1\": 13,\n              \"key 2\": 25,\n              \"name\": 1,\n              \"type 1\": 14,\n              \"type 2\": 26\n            },\n            \"renameByName\": {\n              \"Value #A\": \"Status\",\n              \"Value #B\": \"Response Time\",\n              \"group\": \"Group\",\n              \"group 1\": \"Group\",\n              \"name\": \"Service\"\n            }\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"$datasource\"\n      },\n      \"description\": \"Response times for all monitored services\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 10,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"vis\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"s\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 24\n      },\n      \"id\": 4,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.1.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"$datasource\"\n          },\n          \"expr\": \"gatus_results_duration_seconds\",\n          \"legendFormat\": \"{{name}} ({{group}})\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Response Times\",\n      \"type\": \"timeseries\"\n    }\n  ],\n  \"preload\": false,\n  \"refresh\": \"30s\",\n  \"schemaVersion\": 41,\n  \"tags\": [\n    \"gatus\",\n    \"monitoring\",\n    \"uptime\"\n  ],\n  \"templating\": {\n    \"list\": [\n      {\n        \"current\": {\n          \"text\": \"prometheus\",\n          \"value\": \"cedv077q7bbwgd\"\n        },\n        \"description\": \"Select your Prometheus datasource\",\n        \"includeAll\": false,\n        \"label\": \"Datasource\",\n        \"name\": \"datasource\",\n        \"options\": [],\n        \"query\": \"prometheus\",\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"type\": \"datasource\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-1h\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"\",\n  \"title\": \"Gatus - Service Monitoring Dashboard\",\n  \"uid\": \"4ea25b6f-2edc-416c-8282-a1164f95537a\",\n  \"version\": 1\n}"
  },
  {
    "path": ".examples/docker-compose-grafana-prometheus/grafana/provisioning/datasources/prometheus.yml",
    "content": "apiVersion: 1\n\ndatasources:\n  - name: Prometheus\n    type: prometheus\n    access: proxy\n    url: http://prometheus:9090\n    isDefault: true\n    version: 1\n    editable: false\n"
  },
  {
    "path": ".examples/docker-compose-grafana-prometheus/prometheus/prometheus.yml",
    "content": "scrape_configs:\n  - job_name: gatus\n    scrape_interval: 10s\n    static_configs:\n      - targets:\n          - gatus:8080\n"
  },
  {
    "path": ".examples/docker-compose-mattermost/compose.yaml",
    "content": "services:\n  gatus:\n    container_name: gatus\n    image: twinproduction/gatus:latest\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - ./config:/config\n    networks:\n      - default\n\n  mattermost:\n    container_name: mattermost\n    image: mattermost/mattermost-preview:5.26.0\n    ports:\n      - \"8065:8065\"\n    networks:\n      - default\n\nnetworks:\n  default:\n    driver: bridge\n"
  },
  {
    "path": ".examples/docker-compose-mtls/certs/client/client.crt",
    "content": "-----BEGIN CERTIFICATE-----\nMIIFBjCCAu6gAwIBAgIUHJXHAqywj2v25AgX7pDSZ+LX4iAwDQYJKoZIhvcNAQEL\nBQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTQ1MDFaFw0yOTA0MjQw\nMTQ1MDFaMBExDzANBgNVBAMMBmNsaWVudDCCAiIwDQYJKoZIhvcNAQEBBQADggIP\nADCCAgoCggIBANTmRlS5BNG82mOdrhtRPIBD5U40nEW4CVFm85ZJ4Bge4Ty86juf\naoCnI6AEfwpVnJhXPzjUsMBxJFMbiCB+QTJRpxTphtK7orpbwRHjaDZNaLr1MrUO\nieADGiHw93zVDikD8FP5vG+2XWWA56hY84Ac0TR9GqPjsW0nobMgBNgsRtbYUD0B\nT5QOItK180xQRn4jbys5jRnr161S+Sbg6mglz1LBFBCLmZnhZFZ8FAn87gumbnWN\netSnu9kX6iOXBIaB+3nuHOL4xmAan8tAyen6mPfkXrE5ogovjqFFMTUJOKQoJVp3\nzzm/0XYANxoItFGtdjGMTl5IgI220/6kfpn6PYN7y1kYn5EI+UbobD/CuAhd94p6\naQwOXU53/l+eNH/XnTsL/32QQ6qdq8sYqevlslk1M39kKNewWYCeRzYlCVscQk14\nO3fkyXrtRkz30xrzfjvJQ/VzMi+e5UlemsCuCXTVZ5YyBnuWyY+mI6lZICltZSSX\nVinKzpz+t4Jl7glhKiGHaNAkBX2oLddyf280zw4Cx7nDMPs4uOHONYpm90IxEOJe\nzgJ9YxPK9aaKv2AoYLbvhYyKrVT+TFqoEsbQk4vK0t0Gc1j5z4dET31CSOuxVnnU\nLYwtbILFc0uZrbuOAbEbXtjPpw2OGqWagD0QpkE8TjN0Hd0ibyXyUuz5AgMBAAGj\nVTBTMBEGA1UdEQQKMAiCBmNsaWVudDAdBgNVHQ4EFgQUleILTHG5lT2RhSe9H4fV\nxUh0bNUwHwYDVR0jBBgwFoAUbh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcN\nAQELBQADggIBABq8zjRrDaljl867MXAlmbV7eJkSnaWRFct+N//jCVNnKMYaxyQm\n+UG12xYP0U9Zr9vhsqwyTZTQFx/ZFiiz2zfXPtUAppV3AjE67IlKRbec3qmUhj0H\nRv20eNNWXTl1XTX5WDV5887TF+HLZm/4W2ZSBbS3V89cFhBLosy7HnBGrP0hACne\nZbdQWnnLHJMDKXkZey1H1ZLQQCQdAKGS147firj29M8uzSRHgrR6pvsNQnRT0zDL\nTlTJoxyGTMaoj+1IZvRsAYMZCRb8Yct/v2i/ukIykFWUJZ+1Z3UZhGrX+gdhLfZM\njAP4VQ+vFgwD6NEXAA2DatoRqxbN1ZGJQkvnobWJdZDiYu4hBCs8ugKUTE+0iXWt\nhSyrAVUspFCIeDN4xsXT5b0j2Ps4bpSAiGx+aDDTPUnd881I6JGCiIavgvdFMLCW\nyOXJOZvXcNQwsndkob5fZAEqetjrARsHhQuygEq/LnPc6lWsO8O6UzYArEiKWTMx\nN/5hx12Pb7aaQd1f4P3gmmHMb/YiCQK1Qy5d4v68POeqyrLvAHbvCwEMhBAbnLvw\ngne3psql8s5wxhnzwYltcBUmmAw1t33CwzRBGEKifRdLGtA9pbua4G/tomcDDjVS\nChsHGebJvNxOnsQqoGgozqM2x8ScxmJzIflGxrKmEA8ybHpU0d02Xp3b\n-----END CERTIFICATE-----\n"
  },
  {
    "path": ".examples/docker-compose-mtls/certs/client/client.key",
    "content": "-----BEGIN RSA PRIVATE KEY-----\nMIIJKQIBAAKCAgEA1OZGVLkE0bzaY52uG1E8gEPlTjScRbgJUWbzlkngGB7hPLzq\nO59qgKcjoAR/ClWcmFc/ONSwwHEkUxuIIH5BMlGnFOmG0ruiulvBEeNoNk1ouvUy\ntQ6J4AMaIfD3fNUOKQPwU/m8b7ZdZYDnqFjzgBzRNH0ao+OxbSehsyAE2CxG1thQ\nPQFPlA4i0rXzTFBGfiNvKzmNGevXrVL5JuDqaCXPUsEUEIuZmeFkVnwUCfzuC6Zu\ndY161Ke72RfqI5cEhoH7ee4c4vjGYBqfy0DJ6fqY9+ResTmiCi+OoUUxNQk4pCgl\nWnfPOb/RdgA3Ggi0Ua12MYxOXkiAjbbT/qR+mfo9g3vLWRifkQj5RuhsP8K4CF33\ninppDA5dTnf+X540f9edOwv/fZBDqp2ryxip6+WyWTUzf2Qo17BZgJ5HNiUJWxxC\nTXg7d+TJeu1GTPfTGvN+O8lD9XMyL57lSV6awK4JdNVnljIGe5bJj6YjqVkgKW1l\nJJdWKcrOnP63gmXuCWEqIYdo0CQFfagt13J/bzTPDgLHucMw+zi44c41imb3QjEQ\n4l7OAn1jE8r1poq/YChgtu+FjIqtVP5MWqgSxtCTi8rS3QZzWPnPh0RPfUJI67FW\nedQtjC1sgsVzS5mtu44BsRte2M+nDY4apZqAPRCmQTxOM3Qd3SJvJfJS7PkCAwEA\nAQKCAgAPwAALUStib3aMkLlfpfve1VGyc8FChcySrBYbKS3zOt2Y27T3DOJuesRE\n7fA5Yyn+5H1129jo87XR5s3ZnDLV4SUw2THd3H8RCwFWgcdPinHUBZhnEpial5V9\nq1DzzY3gSj1OSRcVVfLE3pYaEIflvhFasQ1L0JLAq4I9OSzX5+FPEEOnWmB5Ey6k\n/fbuJLDXsLwPAOadDfiFBwgNm0KxdRKdtvugBGPW9s4Fzo9rnxLmjmfKOdmQv96Y\nFI/Vat0Cgmfd661RZpbDvKnTpIsLdzw3zTpAIYOzqImvCT+3AmP2qPhSdV3sPMeR\n047qqyLZOVxEFXLQFiGvL4uxYUPy8k0ZI9xkgOfZ/uASozMWsHkaD04+UDi1+kw5\nnfasZLvOWBW/WE/E1Rfz8IiYTeZbgTnY4CraiLrIRc0LGgD1Df4gNr25+P+LKLyK\n/WW89dl6/397HOFnA7CHi7DaA8+9uZAjOWhoCNDdqAVa3QpDD/3/iRiih26bjJfH\n2+sarxU8GovDZFxWd59BUP3jkukCFH+CliQy72JtLXiuPNPAWeGV9UXxtIu40sRX\nSax/TQytYi2J9NJFZFMTwVueIfzsWc8dyM+IPAYJQxN94xYKQU4+Rb/wqqHgUfjT\n1ZQJb8Cmg56IDY/0EPJWQ0qgnE7TZbY2BOEYbpOzdccwUbcEjQKCAQEA8kVyw4Hw\nnqcDWXjzMhOOoRoF8CNwXBvE2KBzpuAioivGcSkjkm8vLGfQYAbDOVMPFt3xlZS0\n0lQm894176Kk8BiMqtyPRWWOsv4vYMBTqbehKn09Kbh6lM7d7jO7sh5iWf4jt3Bw\nSk4XhZ9oQ/kpnEKiHPymHQY3pVYEyFCGJ8mdS6g/TWiYmjMjkQDVFA4xkiyJ0S5J\nNGYxI+YXtHVTVNSePKvY0h51EqTxsexAphGjXnQ3xoe6e3tVGBkeEkcZlESFD/91\n0iqdc5VtKQOwy6Tj4Awk7oK5/u3tfpyIyo31LQIqreTqMO534838lpyp3CbRdvCF\nQdCNpKFX1gZgmwKCAQEA4Pa9VKO3Aw95fpp0T81xNi+Js/NhdsvQyv9NI9xOKKQU\nhiWxmYmyyna3zliDGlqtlw113JFTNQYl1k1yi4JQPu2gnj8te9nB0yv0RVxvbTOq\nu8K1j9Xmj8XVpcKftusQsZ2xu52ONj3ZOOf22wE4Y6mdQcps+rN6XTHRBn7a5b0v\nZCvWf4CIttdIh51pZUIbZKHTU51uU7AhTCY/wEUtiHwYTT9Wiy9Lmay5Lh2s2PCz\nyPE5Y970nOzlSCUl3bVgY1t0xbQtaO5AJ/iuw/vNw+YAiAIPNDUcbcK5njb//+0E\nuTEtDA6SHeYfsNXGDzxipueKXFHfJLCTXnnT5/1v+wKCAQEA0pF78uNAQJSGe8B9\nF3waDnmwyYvzv4q/J00l19edIniLrJUF/uM2DBFa8etOyMchKU3UCJ9MHjbX+EOd\ne19QngGoWWUD/VwMkBQPF7dxv+QDZwudGmLl3+qAx+Uc8O4pq3AQmQJYBq0jEpd/\nJv0rpk3f2vPYaQebW8+MrpIWWASK+1QLWPtdD0D9W61uhVTkzth5HF9vbuSXN01o\nMwd6WxPFSJRQCihAtui3zV26vtw7sv+t7pbPhT2nsx85nMdBOzXmtQXi4Lz7RpeM\nXgaAJi91g6jqfIcQo7smHVJuLib9/pWQhL2estLBTzUcocced2Mh0Y+xMofSZFF7\nJ2E5mwKCAQAO9npbUdRPYM0c7ZsE385C42COVobKBv5pMhfoZbPRIjC3R3SLmMwK\niWDqWZrGuvdGz79iH0xgf3suyNHwk4dQ2C9RtzQIQ9CPgiHqJx7GLaSSfn3jBkAi\nme7+6nYDDZl7pth2eSFHXE/BaDRUFr2wa0ypXpRnDF78Kd8URoW6uB2Z1QycSGlP\nd/w8AO1Mrdvykozix9rZuCJO1VByMme350EaijbwZQHrQ8DBX3nqp//dQqYljWPJ\nuDv703S0TWcO1LtslvJaQ1aDEhhVsr7Z48dvRGvMdifg6Q29hzz5wcMJqkqrvaBc\nWr0K3v0gcEzDey0JvOxRnWj/5KyChqnXAoIBAQDq6Dsks6BjVP4Y1HaA/NWcZxUU\nEZfNCTA19jIHSUiPbWzWHNdndrUq33HkPorNmFaEIrTqd/viqahr2nXpYiY/7E+V\ncpn9eSxot5J8DB4VI92UG9kixxY4K7QTMKvV43Rt6BLosW/cHxW5XTNhB4JDK+TO\nNlHH48fUp2qJh7/qwSikDG130RVHKwK/5Fv3NQyXTw1/n9bhnaC4eSvV39CNSeb5\nrWNEZcnc9zHT2z1UespzVTxVy4hscrkssXxcCq4bOF4bnDFjfblE43o/KrVr2/Ub\njzpXQrAwXNq7pAkIpin0v40lCeTMosSgQLFqMWmtmlCpBVkyEAc9ZYXc3Vs0\n-----END RSA PRIVATE KEY-----\n"
  },
  {
    "path": ".examples/docker-compose-mtls/certs/server/ca.crt",
    "content": "-----BEGIN CERTIFICATE-----\nMIIE9DCCAtygAwIBAgIUCXgA3IbeA2mn8DQ0E5IxaKBLtf8wDQYJKoZIhvcNAQEL\nBQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTE5MzRaFw0zNDA0MjMw\nMTE5MzRaMBIxEDAOBgNVBAMMB2V4YW1wbGUwggIiMA0GCSqGSIb3DQEBAQUAA4IC\nDwAwggIKAoICAQDLE4aTrVJrAVYksFJt5fIVhEJT5T0cLqvtDRf9hXA5Gowremsl\nVJPBm4qbdImzJZCfCcbVjFEBw8h9xID1JUqRWjJ8BfTnpa4qc1e+xRtnvC+OsUeT\nCCgZvK3TZ5vFsaEbRoNGuiaNq9WSTfjLwTxkK6C3Xogm9uDx73PdRob1TNK5A9mE\nWs3ZyV91+g1phKdlNMRaK+wUrjUjEMLgr0t5A5t6WKefsGrFUDaT3sye3ZxDYuEa\nljt+F8hLVyvkDBAhh6B4S5dQILjp7L3VgOsG7Hx9py1TwCbpWXZEuee/1/2OD8tA\nALsxkvRE1w4AZzLPYRL/dOMllLjROQ4VugU8GVpNU7saK5SeWBw3XHyJ9m8vne3R\ncPWaZTfkwfj8NjCgi9BzBPW8/uw7XZMmQFyTj494OKM3T5JQ5jZ5XD97ONm9h+C/\noOmkcWHz6IwEUu7XV5IESxiFlrq8ByAYF98XPhn2wMMrm2OvHMOwrfw2+5U8je5C\nz70p9kpiGK8qCyjbOl9im975jwFCbl7LSj3Y+0+vRlTG/JA4jNZhXsMJcAxeJpvr\npmm/IzN+uXNQzmKzBHVDw+mTUMPziRsUq4q6WrcuQFZa6kQFGNYWI/eWV8o4AAvp\nHtrOGdSyU19w0QqPW0wHmhsV2XFcn6H/E1Qg6sxWpl45YWJFhNaITxm1EQIDAQAB\no0IwQDAOBgNVHQ8BAf8EBAMCAgQwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU\nbh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcNAQELBQADggIBAKvOh81Gag0r\n0ipYS9aK6rp58b6jPpF6shr3xFiJQVovgSvxNS3aWolh+ZupTCC3H2Q1ZUgatak0\nVyEJVO4a7Tz+1XlA6KErhnORC6HB/fgr5KEGraO3Q1uWonPal5QU8xHFStbRaXfx\nhl/k4LLhIdJqcJE+XX/AL8ekZ3NPDtf9+k4V+RBuarLGuKgOtBB8+1qjSpClmW2B\nDaWPlrLPOr2Sd29WOeWHifwVc6kBGpwM3g5VGdDsNX4Ba5eIG3lX2kUzJ8wNGEf0\nbZxcVbTBY+D4JaV4WXoeFmajjK3EdizRpJRZw3fM0ZIeqVYysByNu/TovYLJnBPs\n5AybnO4RzYONKJtZ1GtQgJyG+80/VffDJeBmHKEiYvE6mvOFEBAcU4VLU6sfwfT1\ny1dZq5G9Km72Fg5kCuYDXTT+PB5VAV3Z6k819tG3TyI4hPlEphpoidRbZ+QS9tK5\nRgHah9EJoM7tDAN/mUVHJHQhhLJDBn+iCBYgSJVLwoE+F39NO9oFPD/ZxhJkbk9b\nLkFnpjrVbwD1CNnawX3I2Eytg1IbbzyviQIbpSAEpotk9pCLMAxTR3a08wrVMwst\n2XVSrgK0uUKsZhCIc+q21k98aeNIINor15humizngyBWYOk8SqV84ZNcD6VlM3Qv\nShSKoAkdKxcGG1+MKPt5b7zqvTo8BBPM\n-----END CERTIFICATE-----\n"
  },
  {
    "path": ".examples/docker-compose-mtls/certs/server/server.crt",
    "content": "-----BEGIN CERTIFICATE-----\nMIIFDjCCAvagAwIBAgITc5Ejz7RzBJ2/PcUMsVhj41RtQDANBgkqhkiG9w0BAQsF\nADASMRAwDgYDVQQDDAdleGFtcGxlMB4XDTI0MDQyNTAxNDQ1N1oXDTI5MDQyNDAx\nNDQ1N1owEDEOMAwGA1UEAwwFbmdpbngwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw\nggIKAoICAQCgbLBnVrBdRkBF2XmJgDTiRqWFPQledzCrkHF4eiUvtEytJhkpoRv2\n+SiRPsjCo3XjwcgQIgSy1sHUV8Sazn7V5ux/XBRovhdhUivzI8JSRYj6qwqdUnOy\ndG1ZEy/VRLsIVfoFB0jKJrZCXMT256xkYTlsgPePDsduO7IPPrTN0/I/qBvINFet\nzgWCl2qlZgF4c/MHljo2TR1KlBv0RJUZbfXPwemUazyMrh/MfQHaHE5pfrmMWFGA\n6yLYHEhG+fy5d3F/1+4J24D2j7deIFmmuJMPSlAPt1UjDm7M/bmoTxDG+1MRXSnN\n647EzzS0TFZspHe2+yBbw6j0MMiWMzNZX2iXGVcswXwrphe7ro6OITynM76gDTuM\nISYXKYHayqW0rHFRlKxMcnmrpf5tBuK7XKyoQv/LbFKI1e+j1bNVe7OZtC88EWRc\nSD8WDLqo/3rsxJkRXRW/49hO1nynHrknXJEpZeRnTyglS+VCzXYD0XzwzPKN7CyN\nCHpYpOcWrAMF+EJnE4WRVyJAAt4C1pGhiwn0yCvLEGXXedI/rR5zmUBKitSe7oMT\nJ82H/VaGtwH0lOD9Jjsv9cb+s1c3tChPDKvgGGDaFnlehKg9TM7p+xc9mnEsitfv\novSGzYHk29nQu/S4QrPfWuCNwM2vP9OQ+VJyzDzSyH8iuPPmkfmK5wIDAQABo18w\nXTAbBgNVHREEFDASggVuZ2lueIIJbG9jYWxob3N0MB0GA1UdDgQWBBT89oboWPBC\noNsSbaNquzrjTza6xDAfBgNVHSMEGDAWgBRuH1ODijHGcclNJprRYsFN6xjDETAN\nBgkqhkiG9w0BAQsFAAOCAgEAeg8QwBTne1IGZMDvIGgs95lifzuTXGVQWEid7VVp\nMmXGRYsweb0MwTUq3gSUc+3OPibR0i5HCJRR04H4U+cIjR6em1foIV/bW6nTaSls\nxQAj92eMmzOo/KtOYqMnk//+Da5NvY0myWa/8FgJ7rK1tOZYiTZqFOlIsaiQMHgp\n/PEkZBP5V57h0PY7T7tEj4SCw3DJ6qzzIdpD8T3+9kXd9dcrrjbivBkkJ23agcG5\nwBcI862ELNJOD7p7+OFsv7IRsoXXYrydaDg8OJQovh4RccRqVEQu3hZdi7cPb8xJ\nG7Gxn8SfSVcPg/UObiggydMl8E8QwqWAzJHvl1KUECd5QG6eq984JTR7zQB2iGb6\n1qq+/d9uciuB2YY2h/0rl3Fjy6J6k3fpQK577TlJjZc0F4WH8fW5bcsyGTszxQLI\njQ6FuSOr55lZ9O3R3+95tAdJTrWsxX7j7xMIAXSYrfNt5HM91XNhqISF4SIZOBB6\nenVrrJ/oCFqVSbYf6RVQz3XmPEEMh+k9KdwvIvwoS9NivLD3QH0RjhTyzHbf+LlR\nrWM46XhmBwajlpnIuuMp6jZcXnbhTO1SheoRVMdijcnW+zrmx5oyn3peCfPqOVLz\n95YfJUIFCt+0p/87/0Mm76uVemK6kFKZJQPnfbAdsKF7igPZfUQx6wZZP1qK9ZEU\neOk=\n-----END CERTIFICATE-----\n"
  },
  {
    "path": ".examples/docker-compose-mtls/certs/server/server.key",
    "content": "-----BEGIN RSA PRIVATE KEY-----\nMIIJKQIBAAKCAgEAoGywZ1awXUZARdl5iYA04kalhT0JXncwq5BxeHolL7RMrSYZ\nKaEb9vkokT7IwqN148HIECIEstbB1FfEms5+1ebsf1wUaL4XYVIr8yPCUkWI+qsK\nnVJzsnRtWRMv1US7CFX6BQdIyia2QlzE9uesZGE5bID3jw7HbjuyDz60zdPyP6gb\nyDRXrc4FgpdqpWYBeHPzB5Y6Nk0dSpQb9ESVGW31z8HplGs8jK4fzH0B2hxOaX65\njFhRgOsi2BxIRvn8uXdxf9fuCduA9o+3XiBZpriTD0pQD7dVIw5uzP25qE8QxvtT\nEV0pzeuOxM80tExWbKR3tvsgW8Oo9DDIljMzWV9olxlXLMF8K6YXu66OjiE8pzO+\noA07jCEmFymB2sqltKxxUZSsTHJ5q6X+bQbiu1ysqEL/y2xSiNXvo9WzVXuzmbQv\nPBFkXEg/Fgy6qP967MSZEV0Vv+PYTtZ8px65J1yRKWXkZ08oJUvlQs12A9F88Mzy\njewsjQh6WKTnFqwDBfhCZxOFkVciQALeAtaRoYsJ9MgryxBl13nSP60ec5lASorU\nnu6DEyfNh/1WhrcB9JTg/SY7L/XG/rNXN7QoTwyr4Bhg2hZ5XoSoPUzO6fsXPZpx\nLIrX76L0hs2B5NvZ0Lv0uEKz31rgjcDNrz/TkPlScsw80sh/Irjz5pH5iucCAwEA\nAQKCAgADiEEeFV+OvjQ+FXrCl0sSzGFqnJxvMwqkTGrjLzVQZpTlnxggvYZjGrtU\n71/2QSkgWazxBf66fVYJOeF/Uxqh1RLR/xIH+F+FagzDrr7hltxcQJXcPuuDO2MI\n+g4skPXZSiNWJwHoSY/ryCUiFpnKIAXmqLRKtxWXDMNv6H6MpaUI18e80cI4dnfS\nl0jm2Wcg4tSwDxO7DFmfwcEX0MbDp5Mo/ukIto+/vTnAA+Sdi9ACLKMjPvKUdxju\nTzkcLvbskn+yQ+ve1bFyPFnaPbYboKbESGuY3P2H5xJzewayeQMyjmgW0slP2mbr\nWHCdo6ynebuVENR2kMlQjx5riDcSMMX5TLGPgNL7ZBf2b52mUgFyQb27eO2WXeyH\nYLtInlKA44bdi76sDK+s8zYywZnxsUy7xrKhHE5rqz964EfoLRcY/fCm7XnMo6uK\nVviBtdPebsMqkZOUKSaYSRpUgXILTud5FD+m68FeVjUvQFQqHYEa3gx+rAIjKBIn\n082NzfDZSHVsvG+iB5q+37R8C0/YUzSb3TXys5pA82YsjIFeQiVE4hrV1yeNIZf6\n2iaPD/r5H3vt0rFEDINZafC+6bTTRQoq8TOCZFh/Lu+ynXKOPrVUF8/y3sd8+T2v\nkRDOL37reUotjE1lbO4RhLgHbeWHlT/PPnF7RDKCe6/erg2MqQKCAQEAy3f8B6I8\n7CP4CZmMDWwHWsjMS/HGZgvPPbmWhaeZZmFyYi7I8MruJPhlhlw6YoUIV9Vvp8zE\neLtDvZ5WXuL38aRElWzNyrhrU1/vH4pkaFk+OgRcaleGUof+go0lE8BIYnWoWovo\n/F7lQMQmHY4SuwF4oj6dpus7jMm41PQqDTsjofdLgwVAGy30LIkVt8qYha77sL8N\n0ohXomDGik0nVa+i2mOJ0UuooGYF8WhujzVcELcerYvvg9kFDqJaEXdfTx4DRwiz\n6f5gSbZHME7moqEkcJRtwj8TXSJYRHTI8ngS0xzyV0u2RL3FOxTcgikJIkmU6W3L\nIcbP6XVlrCdoswKCAQEAydfBcsYcS2mMqCOdKkGVj6zBriT78/5dtPYeId9WkrnX\n1vz6ErjHQ8vZkduvCm3KkijQvva+DFV0sv24qTyA2BIoDUJdk7cY962nR4Q9FHTX\nDkn1kgeKg4TtNdgo2KsIUn7bCibKASCExo6rO3PWiQyF+jTJVDD3rXx7+7N7WJaz\nzTVt6BNOWoIjTufdXfRWt3wi0H6sSkqvRWoIAaguXkKXH7oBx0gKs+oAVovFvg7A\nLLEtTszsv2LmbpGWaiT3Ny215mA0ZGI9T4utK7oUgd+DlV0+vj5tFfsye4COpCyG\nV/ZQ7CBbxHDDak3R3fYy5pOwmh6814wHMyKKfdGm/QKCAQEAiW4Pk3BnyfA5lvJZ\ngK9ZAF7kbt9tbHvJjR2Pp9Meb+KeCecj3lCTLfGBUZF19hl5GyqU8jgC9LE3/hm2\nqPyREGwtzufg0G5kP7pqn1kwnLK6ryFG8qUPmys0IyYGxyJ3QdnKzu31fpDyNB7I\nx+mwiRNjUeMNRTNZ06xk5aHNzYYGeV25aVPgivstE++79ZooDxOz+Rvy0CM7XfgT\n4lJeoSeyzeOxsOZzjXObzAUHuD8IYlntpLcCHoI1Qj8yqt2ASMYy3IXqT8B7dQ5j\nYyPH8Ez7efcnc656+8s453QiTnP/8wx4O7Jt+FxdnZxnnJrvCnO82zZHoBbTVBLx\ni6hKtQKCAQA0j3SWmLRBhwjTuAJzQITb1xbQbF0X2oM4XmbWVzxKFQ75swLD4U4y\nf2D2tIhOZOy9RtelAsfWmmI7QgrWNyUuHvxDB6cqkiF0Tcoju3HUY+CknenOzxvo\nx7KltNZeJZuTL+mGKTetN3Sb6Ab7Al05bwNsdlZ/EAlPKf13O/PAy+2iYGlwZ6ad\ntwnOwF5K2xfBzBecx3/CENS3dLcFB3CbpyeHYX6ZEE+JLkRMRTWHGnw8px6vSHnW\nFMEAxfSvS1T9D3Awv5ilE1f34N2FZ31znGq9eHygOc1aTgGFW6LJabbKLSBBfOOo\nsdyRUBZ4gGYc2RTB7YMrdhFh5Xq+7NtZAoIBAQCOJ3CLecp/rS+lGy7oyx4f6QDd\nzH/30Y/uvXLPUj+Ljg9bMTG9chjaKfyApXv6rcQI0d6wrqAunNl1b3opBQjsGCSt\nbpBV/rGg3sl752og6KU1PCZ2KkVYPjugNhqPGonNh8tlw+1xFyBdt0c68g/auIHq\nWaT5tWVfP01Ri43RjyCgNtJ2TJUzbA40BteDHPWKeM1lZ6e92fJTp5IjQ/Okc41u\nElr7p22fx/N04JTX9G6oGdxM7Gh2Uf4i4PnNOi+C3xqLrtUEi/OLof2UHlatypt9\npix0bXJtZE7WfFfesQIxGffVBhgN3UgqhAf2wquHgm1O17JXrmkR6JSYNpKc\n-----END RSA PRIVATE KEY-----\n"
  },
  {
    "path": ".examples/docker-compose-mtls/compose.yaml",
    "content": "services:\n  nginx:\n    image: nginx:stable\n    volumes:\n      - ./certs/server:/etc/nginx/certs\n      - ./nginx:/etc/nginx/conf.d\n    ports:\n      - \"8443:443\"\n    networks:\n      - mtls\n\n  gatus:\n    image: twinproduction/gatus:latest\n    restart: always\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - ./config:/config\n      - ./certs/client:/certs\n    environment:\n      - GATUS_CONFIG_PATH=/config\n    networks:\n      - mtls\n\nnetworks:\n  mtls:\n"
  },
  {
    "path": ".examples/docker-compose-mtls/nginx/default.conf",
    "content": "server {\n    listen                  443 ssl;\n\n    ssl_certificate         /etc/nginx/certs/server.crt;\n    ssl_certificate_key     /etc/nginx/certs/server.key;\n    ssl_client_certificate  /etc/nginx/certs/ca.crt;\n    ssl_verify_client       on;\n\n    location / {\n      if ($ssl_client_verify != SUCCESS) {\n        return 403;\n      }\n      root   /usr/share/nginx/html;\n      index  index.html index.htm;\n    }\n}"
  },
  {
    "path": ".examples/docker-compose-multiple-config-files/compose.yaml",
    "content": "services:\n  gatus:\n    image: twinproduction/gatus:latest\n    ports:\n      - \"8080:8080\"\n    environment:\n      - GATUS_CONFIG_PATH=/config\n    volumes:\n      - ./config:/config"
  },
  {
    "path": ".examples/docker-compose-multiple-config-files/config/backend.yaml",
    "content": "endpoints:\n  - name: check-if-api-is-healthy\n    group: backend\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 1000\"\n\n  - name: check-if-website-is-pingable\n    url: \"icmp://example.org\"\n    interval: 1m\n    conditions:\n      - \"[CONNECTED] == true\"\n\n  - name: check-domain-expiration\n    url: \"https://example.org\"\n    interval: 6h\n    conditions:\n      - \"[DOMAIN_EXPIRATION] > 720h\"\n"
  },
  {
    "path": ".examples/docker-compose-multiple-config-files/config/frontend.yaml",
    "content": "endpoints:\n  - name: make-sure-html-rendering-works\n    group: frontend\n    url: \"https://example.org\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY] == pat(*<h1>Example Domain</h1>*)\" # Check for header in HTML page\n"
  },
  {
    "path": ".examples/docker-compose-multiple-config-files/config/global.yaml",
    "content": "metrics: true\nui:\n  header: Example Company\n  link: https://example.org\n  buttons:\n    - name: \"Home\"\n      link: \"https://example.org\"\n"
  },
  {
    "path": ".examples/docker-compose-postgres-storage/compose.yaml",
    "content": "services:\n  postgres:\n    image: postgres\n    volumes:\n      - ./data/db:/var/lib/postgresql/data\n    ports:\n      - \"5432:5432\"\n    environment:\n      - POSTGRES_DB=gatus\n      - POSTGRES_USER=username\n      - POSTGRES_PASSWORD=password\n    networks:\n      - web\n\n  gatus:\n    image: twinproduction/gatus:latest\n    restart: always\n    ports:\n      - \"8080:8080\"\n    environment:\n      - POSTGRES_USER=username\n      - POSTGRES_PASSWORD=password\n      - POSTGRES_DB=gatus\n    volumes:\n      - ./config:/config\n    networks:\n      - web\n    depends_on:\n      - postgres\n\nnetworks:\n  web:\n"
  },
  {
    "path": ".examples/docker-compose-sqlite-storage/compose.yaml",
    "content": "services:\n  gatus:\n    image: twinproduction/gatus:latest\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - ./config:/config\n      - ./data:/data/\n"
  },
  {
    "path": ".examples/docker-compose-sqlite-storage/data/.gitkeep",
    "content": ""
  },
  {
    "path": ".examples/docker-minimal/Dockerfile",
    "content": "FROM twinproduction/gatus\nADD config.yaml ./config/config.yaml"
  },
  {
    "path": ".examples/kubernetes/gatus.yaml",
    "content": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: gatus\n  namespace: kube-system\ndata:\n  config.yaml: |\n    metrics: true\n    endpoints:\n      - name: website\n        url: https://twin.sh/health\n        interval: 5m\n        conditions:\n          - \"[STATUS] == 200\"\n          - \"[BODY].status == UP\"\n\n      - name: github\n        url: https://api.github.com/healthz\n        interval: 5m\n        conditions:\n          - \"[STATUS] == 200\"\n\n      - name: cat-fact\n        url: \"https://cat-fact.herokuapp.com/facts/random\"\n        interval: 5m\n        conditions:\n          - \"[STATUS] == 200\"\n          - \"[BODY].deleted == false\"\n          - \"len([BODY].text) > 0\"\n          - \"[BODY].text == pat(*cat*)\"\n          - \"[STATUS] == pat(2*)\"\n          - \"[CONNECTED] == true\"\n\n      - name: example\n        url: https://example.com/\n        conditions:\n          - \"[STATUS] == 200\"\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: gatus\n  namespace: kube-system\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: gatus\n  namespace: kube-system\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: gatus\n  template:\n    metadata:\n      name: gatus\n      namespace: kube-system\n      labels:\n        app: gatus\n    spec:\n      serviceAccountName: gatus\n      terminationGracePeriodSeconds: 5\n      containers:\n        - image: twinproduction/gatus\n          imagePullPolicy: IfNotPresent\n          name: gatus\n          ports:\n            - containerPort: 8080\n              name: http\n              protocol: TCP\n          resources:\n            limits:\n              cpu: 250m\n              memory: 100M\n            requests:\n              cpu: 50m\n              memory: 30M\n          readinessProbe:\n            httpGet:\n              path: /health\n              port: 8080\n            initialDelaySeconds: 5\n            periodSeconds: 10\n            successThreshold: 1\n            failureThreshold: 3\n          livenessProbe:\n            httpGet:\n              path: /health\n              port: 8080\n            initialDelaySeconds: 10\n            periodSeconds: 10\n            successThreshold: 1\n            failureThreshold: 5\n          volumeMounts:\n            - mountPath: /config\n              name: gatus-config\n      volumes:\n        - configMap:\n            name: gatus\n          name: gatus-config\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: gatus\n  namespace: kube-system\nspec:\n  ports:\n    - name: http\n      port: 8080\n      protocol: TCP\n      targetPort: 8080\n  selector:\n    app: gatus"
  },
  {
    "path": ".examples/nixos/README.md",
    "content": "# NixOS\n\nGatus is implemented as a NixOS module. See [gatus.nix](./gatus.nix) for example\nusage.\n"
  },
  {
    "path": ".examples/nixos/gatus.nix",
    "content": "{\n  services.gatus = {\n    enable = true;\n\n    settings = {\n      web.port = 8080;\n\n      endpoints = [\n        {\n          name = \"website\";\n          url = \"https://twin.sh/health\";\n          interval = \"5m\";\n\n          conditions = [\n            \"[STATUS] == 200\"\n            \"[BODY].status == UP\"\n            \"[RESPONSE_TIME] < 300\"\n          ];\n        }\n      ];\n    };\n  };\n}\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [TwiN]\n"
  },
  {
    "path": ".github/assets/gatus-diagram.drawio",
    "content": "<mxfile host=\"app.diagrams.net\" modified=\"2022-12-07T04:00:31.242Z\" agent=\"5.0 (Windows)\" etag=\"4-CttOJPoGYGt_6RMEMf\" version=\"20.5.3\" type=\"device\"><diagram id=\"oCf8YAkR0GE5Fy88uv5t\" name=\"Page-1\">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==</diagram></mxfile>"
  },
  {
    "path": ".github/codecov.yml",
    "content": "ignore:\n  - \"storage/store/sql/specific_postgres.go\" # Can't test for postgres\n  - \"watchdog/endpoint.go\"\n  - \"watchdog/external_endpoint.go\"\n  - \"watchdog/suite.go\"\n  - \"watchdog/watchdog.go\"\ncomment: false\ncoverage:\n  status:\n    patch: off\n    project:\n      default:\n        target: 70%\n        threshold: null\n\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    labels: [\"dependencies\"]\n    schedule:\n      interval: \"daily\"\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    open-pull-requests-limit: 3\n    labels: [\"dependencies\"]\n    schedule:\n      interval: \"daily\"\n"
  },
  {
    "path": ".github/workflows/benchmark.yml",
    "content": "name: benchmark\non:\n  workflow_run:\n    workflows: [publish-latest]\n    branches: [master]\n    types: [completed]\n  workflow_dispatch:\n    inputs:\n      repository:\n        description: \"Repository to checkout. Useful for benchmarking a fork. Format should be <owner>/<repository>.\"\n        required: true\n        default: \"TwiN/gatus\"\n      ref:\n        description: \"Branch, tag or SHA to checkout\"\n        required: true\n        default: \"master\"\njobs:\n  build:\n    name: benchmark\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n    steps:\n      - uses: actions/setup-go@v6\n        with:\n          go-version: 1.25.5\n          repository: \"${{ github.event.inputs.repository || 'TwiN/gatus' }}\"\n          ref: \"${{ github.event.inputs.ref || 'master' }}\"\n      - uses: actions/checkout@v5\n      - name: Benchmark\n        run: go test -bench=. ./storage/store\n"
  },
  {
    "path": ".github/workflows/labeler.yml",
    "content": "name: labeler\non:\n  pull_request_target:\n    types:\n      - opened\n  issues:\n    types:\n      - opened\njobs:\n  labeler:\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    permissions:\n      issues: write\n      pull-requests: write\n    steps:\n      - name: Label\n        continue-on-error: true\n        env:\n          TITLE: ${{ github.event.issue.title }}${{ github.event.pull_request.title }}\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          GH_REPO: ${{ github.repository }}\n          NUMBER: ${{ github.event.issue.number }}${{ github.event.pull_request.number }}\n        run: |\n          if [[ $TITLE == \"feat\"* ]]; then\n            gh issue edit \"$NUMBER\" --add-label \"feature\"\n          elif [[ $TITLE == \"fix\"* ]]; then\n            gh issue edit \"$NUMBER\" --add-label \"bug\"\n          elif [[ $TITLE == \"docs\"* ]]; then\n            gh issue edit \"$NUMBER\" --add-label \"documentation\"\n          fi\n          if [[ $TITLE == *\"alerting\"* || $TITLE == *\"provider\"* || $TITLE == *\"alert\"* ]]; then\n            gh issue edit \"$NUMBER\" --add-label \"area/alerting\"\n          fi\n          if [[ $TITLE == *\"(ui)\"* || $TITLE == *\"ui:\"* ]]; then\n            gh issue edit \"$NUMBER\" --add-label \"area/ui\"\n          fi\n          if [[ $TITLE == *\"storage\"* || $TITLE == *\"postgres\"* || $TITLE == *\"sqlite\"* ]]; then\n            gh issue edit \"$NUMBER\" --add-label \"area/storage\"\n          fi\n          if [[ $TITLE == *\"security\"* || $TITLE == *\"oidc\"* || $TITLE == *\"oauth2\"* ]]; then\n            gh issue edit \"$NUMBER\" --add-label \"area/security\"\n          fi\n          if [[ $TITLE == *\"metric\"* || $TITLE == *\"prometheus\"* ]]; then\n            gh issue edit \"$NUMBER\" --add-label \"area/metrics\"\n          fi\n"
  },
  {
    "path": ".github/workflows/publish-custom.yml",
    "content": "name: publish-custom\nrun-name: \"${{ inputs.tag }}\"\non:\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: Custom tag to publish\n      platforms:\n        description: Platforms to publish to (comma separated list)\n        default: linux/amd64\n        type: choice\n        options:\n          - linux/amd64\n          - linux/arm/v7\n          - linux/arm64\n\njobs:\n  publish-custom:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    steps:\n      - uses: actions/checkout@v5\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n      - name: Get image repository\n        run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: ${{ env.GHCR_IMAGE_REPOSITORY }}\n          tags: |\n            type=raw,value=${{ inputs.tag }}\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        with:\n          platforms: ${{ inputs.platforms }}\n          pull: true\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/publish-experimental.yml",
    "content": "name: publish-experimental\non: [workflow_dispatch]\njobs:\n  publish-experimental:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    steps:\n      - uses: actions/checkout@v5\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n      - name: Get image repository\n        run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV\n      - name: Login to Docker Registry\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: ${{ env.IMAGE_REPOSITORY }}\n          tags: |\n            type=raw,value=experimental\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        with:\n          platforms: linux/amd64\n          pull: true\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/publish-latest.yml",
    "content": "name: publish-latest\non:\n  workflow_run:\n    workflows: [test]\n    branches: [master]\n    types: [completed]\nconcurrency:\n  group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}\n  cancel-in-progress: true\njobs:\n  publish-latest:\n    runs-on: ubuntu-latest\n    if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}\n    timeout-minutes: 240\n    steps:\n      - uses: actions/checkout@v5\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n      - name: Get image repository\n        run: |\n          echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV\n          echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV\n      - name: Login to Docker Registry\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: |\n            ${{ env.DOCKER_IMAGE_REPOSITORY }}\n            ${{ env.GHCR_IMAGE_REPOSITORY }}\n          tags: |\n            type=raw,value=latest\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        with:\n          platforms: linux/amd64,linux/arm/v7,linux/arm64\n          pull: true\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/publish-release.yml",
    "content": "name: publish-release\non:\n  release:\n    types: [published]\njobs:\n  publish-release:\n    name: publish-release\n    runs-on: ubuntu-latest\n    timeout-minutes: 240\n    steps:\n      - uses: actions/checkout@v5\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n      - name: Get image repository\n        run: |\n          echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV\n          echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV\n      - name: Get the release\n        run: echo RELEASE=${GITHUB_REF/refs\\/tags\\//} >> $GITHUB_ENV\n      - name: Login to Docker Registry\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v6\n        with:\n          images: |\n            ${{ env.DOCKER_IMAGE_REPOSITORY }}\n            ${{ env.GHCR_IMAGE_REPOSITORY }}\n          tags: |\n            type=raw,value=${{ env.RELEASE }}\n            type=raw,value=stable\n            type=raw,value=latest\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        with:\n          platforms: linux/amd64,linux/arm/v7,linux/arm64\n          pull: true\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/regenerate-static-assets.yml",
    "content": "name: regenerate-static-assets\non:\n  issue_comment:\n    types: [created]\n\njobs:\n  check-command:\n    runs-on: ubuntu-latest\n    if: ${{ github.event.issue.pull_request }}\n    permissions:\n      pull-requests: write # required for adding reactions to command comments on PRs\n      checks: read # required to check if all ci checks have passed\n    outputs:\n      continue: ${{ steps.command.outputs.continue }}\n    steps:\n      - name: Check command trigger\n        id: command\n        uses: github/command@v2\n        with:\n          command: \"/regenerate-static-assets\"\n          permissions: \"write,admin\" # The allowed permission levels to invoke this command\n          allow_forks: true\n          allow_drafts: true\n          skip_ci: true\n          skip_completing: true\n\n  regenerate-static-assets:\n    runs-on: ubuntu-latest\n    needs: check-command\n    if: ${{ needs.check-command.outputs.continue == 'true' }}\n    permissions:\n      contents: write\n    outputs:\n      status: ${{ steps.commit.outputs.status }}\n    steps:\n      - name: Get PR branch\n        id: pr\n        uses: actions/github-script@v8\n        with:\n          script: |\n            const pr = await github.rest.pulls.get({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              pull_number: context.issue.number\n            });\n            core.setOutput('ref', pr.data.head.ref);\n            core.setOutput('repo', pr.data.head.repo.full_name);\n      - name: Checkout PR branch\n        uses: actions/checkout@v6\n        with:\n          repository: ${{ steps.pr.outputs.repo }}\n          ref: ${{ steps.pr.outputs.ref }}\n      - name: Regenerate static assets\n        run: |\n          make frontend-install-dependencies\n          make frontend-build\n      - name: Commit and push changes\n        id: commit\n        run: |\n          echo \"Checking for changes...\"\n          if git diff --quiet; then\n            echo \"No changes detected.\"\n            echo \"status=no_changes\" >> $GITHUB_OUTPUT\n            exit 0\n          fi\n          git config --global user.name \"github-actions[bot]\"\n          git config --global user.email \"github-actions[bot]@users.noreply.github.com\"\n          echo \"Changes detected. Committing and pushing...\"\n          git add . \n          git commit -m \"chore(ui): Regenerate static assets\"\n          git push origin ${{ steps.pr.outputs.ref }}\n          echo \"status=success\" >> $GITHUB_OUTPUT\n\n  create-response-comment:\n    runs-on: ubuntu-latest\n    needs: [check-command, regenerate-static-assets]\n    if: ${{ !cancelled() && needs.check-command.outputs.continue == 'true' }}\n    permissions:\n      pull-requests: write\n    steps:\n      - name: Create response comment\n        uses: actions/github-script@v8\n        with:\n          script: |\n            const status = '${{ needs.regenerate-static-assets.outputs.status }}';\n            let reaction = 'hooray';\n            let message = '';\n            var workflowUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;\n            if (status === 'no_changes') {\n              message = `@${context.actor} No changes to commit ([ref](${workflowUrl})).`;\n            } else {\n              reaction = '-1';\n              message = `@${context.actor} There was an issue regenerating static assets. Please check the [workflow run logs](${workflowUrl}) for more details.`;\n            }\n            if (message.length) {\n              await github.rest.issues.createComment({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: context.issue.number,\n                body: message\n              });\n            }\n            await github.rest.reactions.createForIssueComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              comment_id: context.payload.comment.id,\n              content: reaction\n            });\n"
  },
  {
    "path": ".github/workflows/test-ui.yml",
    "content": "name: test-ui\non:\n  pull_request:\n    paths:\n      - 'web/**'\n  push:\n    branches:\n      - master\n    paths:\n      - 'web/**'\njobs:\n  test-ui:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    steps:\n      - uses: actions/checkout@v5\n      - run: make frontend-install-dependencies\n      - run: make frontend-build"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: test\non:\n  pull_request:\n    paths-ignore:\n      - '*.md'\n      - '.examples/**'\n  push:\n    branches:\n      - master\n    paths-ignore:\n      - '*.md'\n      - '.github/**'\n      - '.examples/**'\njobs:\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    steps:\n      - uses: actions/setup-go@v6\n        with:\n          go-version: 1.25.5\n      - uses: actions/checkout@v5\n      - name: Build binary to make sure it works\n        run: go build\n      - name: Test\n        # We're using \"sudo\" because one of the tests leverages ping, which requires super-user privileges.\n        # As for the 'env \"PATH=$PATH\" \"GOROOT=$GOROOT\"', we need it to use the same \"go\" executable that\n        # was configured by the \"Set up Go\" step (otherwise, it'd use sudo's \"go\" executable)\n        run: sudo env \"PATH=$PATH\" \"GOROOT=$GOROOT\" go test ./... -race -coverprofile=coverage.txt -covermode=atomic\n      - name: Codecov\n        uses: codecov/codecov-action@v5.5.2\n        with:\n          files: ./coverage.txt\n          token: ${{ secrets.CODECOV_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# IDE\n*.iml\n.idea\n.vscode\n\n# OS\n.DS_Store\n\n# JS\nnode_modules\n\n# Go\n/vendor\n\n# Misc\n*.db\n*.db-shm\n*.db-wal\ngatus\nconfig/config.yml\nconfig.yaml"
  },
  {
    "path": "Dockerfile",
    "content": "# Build the go application into a binary\nFROM golang:alpine AS builder\nRUN apk --update add ca-certificates\nWORKDIR /app\nCOPY . ./\nRUN go mod tidy -diff\nRUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .\n\n# Run Tests inside docker image if you don't have a configured go environment\n#RUN apk update && apk add --virtual build-dependencies build-base gcc\n#RUN go test ./... -mod vendor\n\n# Run the binary on an empty container\nFROM scratch\nCOPY --from=builder /app/gatus .\nCOPY --from=builder /app/config.yaml ./config/config.yaml\nCOPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\nENV GATUS_CONFIG_PATH=\"\"\nENV GATUS_LOG_LEVEL=\"INFO\"\nENV PORT=\"8080\"\nEXPOSE ${PORT}\nENTRYPOINT [\"/gatus\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n\n"
  },
  {
    "path": "Makefile",
    "content": "BINARY=gatus\n\n.PHONY: install\ninstall:\n\tgo build -v -o $(BINARY) .\n\n.PHONY: run\nrun:\n\tENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml go run main.go\n\n.PHONY: run-binary\nrun-binary:\n\tENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)\n\n.PHONY: clean\nclean:\n\trm $(BINARY)\n\n.PHONY: test\ntest:\n\tgo test ./... -cover\n\n\n##########\n# Docker #\n##########\n\ndocker-build:\n\tdocker build -t twinproduction/gatus:latest .\n\ndocker-run:\n\tdocker run -p 8080:8080 --name gatus twinproduction/gatus:latest\n\ndocker-build-and-run: docker-build docker-run\n\n\n#############\n# Front end #\n#############\n\nfrontend-install-dependencies:\n\tnpm --prefix web/app install\n\nfrontend-build:\n\tnpm --prefix web/app run build\n\nfrontend-run:\n\tnpm --prefix web/app run serve\n"
  },
  {
    "path": "README.md",
    "content": "[![Gatus](.github/assets/logo-with-dark-text.png)](https://gatus.io)\n\n![test](https://github.com/TwiN/gatus/actions/workflows/test.yml/badge.svg)\n[![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus)\n[![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus)\n[![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus)\n[![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)\n[![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN)\n\nGatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS\nqueries as well as evaluate the result of said queries by using a list of conditions on values like the status code,\nthe response time, the certificate expiration, the body and many others. The icing on top is that each of these health\nchecks can be paired with alerting via Slack, Teams, PagerDuty, Discord, Twilio and many more.\n\nI personally deploy it in my Kubernetes cluster and let it monitor the status of my\ncore applications: https://status.twin.sh/\n\n_Looking for a managed solution? Check out [Gatus.io](https://gatus.io)._\n\n<details>\n  <summary><b>Quick start</b></summary>\n\n```console\ndocker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable\n```\n\nYou can also use Docker Hub if you prefer:\n```console\ndocker run -p 8080:8080 --name gatus twinproduction/gatus:stable\n```\nFor more details, see [Usage](#usage)\n</details>\n\n> ❤ Like this project? Please consider [sponsoring me](https://github.com/sponsors/TwiN).\n\n![Gatus dashboard](.github/assets/dashboard-dark.jpg)\n\nHave any feedback or questions? [Create a discussion](https://github.com/TwiN/gatus/discussions/new).\n\n\n## Table of Contents\n- [Table of Contents](#table-of-contents)\n- [Why Gatus?](#why-gatus)\n- [Features](#features)\n- [Usage](#usage)\n- [Configuration](#configuration)\n  - [Endpoints](#endpoints)\n  - [External Endpoints](#external-endpoints)\n  - [Suites (ALPHA)](#suites-alpha)\n  - [Conditions](#conditions)\n    - [Placeholders](#placeholders)\n    - [Functions](#functions)\n  - [Web](#web)\n  - [UI](#ui)\n  - [Announcements](#announcements)\n  - [Storage](#storage)\n  - [Client configuration](#client-configuration)\n  - [Tunneling](#tunneling)\n  - [Alerting](#alerting)\n    - [Configuring AWS SES alerts](#configuring-aws-ses-alerts)\n    - [Configuring ClickUp alerts](#configuring-clickup-alerts)\n    - [Configuring Datadog alerts](#configuring-datadog-alerts)\n    - [Configuring Discord alerts](#configuring-discord-alerts)\n    - [Configuring Email alerts](#configuring-email-alerts)\n    - [Configuring Gitea alerts](#configuring-gitea-alerts)\n    - [Configuring GitHub alerts](#configuring-github-alerts)\n    - [Configuring GitLab alerts](#configuring-gitlab-alerts)\n    - [Configuring Google Chat alerts](#configuring-google-chat-alerts)\n    - [Configuring Gotify alerts](#configuring-gotify-alerts)\n    - [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts)\n    - [Configuring IFTTT alerts](#configuring-ifttt-alerts)\n    - [Configuring Ilert alerts](#configuring-ilert-alerts)\n    - [Configuring Incident.io alerts](#configuring-incidentio-alerts)\n    - [Configuring Line alerts](#configuring-line-alerts)\n    - [Configuring Matrix alerts](#configuring-matrix-alerts)\n    - [Configuring Mattermost alerts](#configuring-mattermost-alerts)\n    - [Configuring Messagebird alerts](#configuring-messagebird-alerts)\n    - [Configuring n8n alerts](#configuring-n8n-alerts)\n    - [Configuring New Relic alerts](#configuring-new-relic-alerts)\n    - [Configuring Ntfy alerts](#configuring-ntfy-alerts)\n    - [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)\n    - [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)\n    - [Configuring Plivo alerts](#configuring-plivo-alerts)\n    - [Configuring Pushover alerts](#configuring-pushover-alerts)\n    - [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts)\n    - [Configuring SendGrid alerts](#configuring-sendgrid-alerts)\n    - [Configuring Signal alerts](#configuring-signal-alerts)\n    - [Configuring SIGNL4 alerts](#configuring-signl4-alerts)\n    - [Configuring Slack alerts](#configuring-slack-alerts)\n    - [Configuring Splunk alerts](#configuring-splunk-alerts)\n    - [Configuring Squadcast alerts](#configuring-squadcast-alerts)\n    - [Configuring Teams alerts *(Deprecated)*](#configuring-teams-alerts-deprecated)\n    - [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts)\n    - [Configuring Telegram alerts](#configuring-telegram-alerts)\n    - [Configuring Twilio alerts](#configuring-twilio-alerts)\n    - [Configuring Vonage alerts](#configuring-vonage-alerts)\n    - [Configuring Webex alerts](#configuring-webex-alerts)\n    - [Configuring Zapier alerts](#configuring-zapier-alerts)\n    - [Configuring Zulip alerts](#configuring-zulip-alerts)\n    - [Configuring custom alerts](#configuring-custom-alerts)\n    - [Setting a default alert](#setting-a-default-alert)\n  - [Maintenance](#maintenance)\n  - [Security](#security)\n    - [Basic Authentication](#basic-authentication)\n    - [OIDC](#oidc)\n  - [TLS Encryption](#tls-encryption)\n  - [Metrics](#metrics)\n    - [Custom Labels](#custom-labels)\n  - [Connectivity](#connectivity)\n  - [Remote instances (EXPERIMENTAL)](#remote-instances-experimental)\n- [Deployment](#deployment)\n  - [Docker](#docker)\n  - [Helm Chart](#helm-chart)\n  - [Terraform](#terraform)\n    - [Kubernetes](#kubernetes)\n- [Running the tests](#running-the-tests)\n- [Using in Production](#using-in-production)\n- [FAQ](#faq)\n  - [Sending a GraphQL request](#sending-a-graphql-request)\n  - [Recommended interval](#recommended-interval)\n  - [Default timeouts](#default-timeouts)\n  - [Monitoring a TCP endpoint](#monitoring-a-tcp-endpoint)\n  - [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint)\n  - [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint)\n  - [Monitoring a WebSocket endpoint](#monitoring-a-websocket-endpoint)\n  - [Monitoring an endpoint using gRPC](#monitoring-an-endpoint-using-grpc)\n  - [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp)\n  - [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)\n  - [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)\n  - [Monitoring an endpoint using STARTTLS](#monitoring-an-endpoint-using-starttls)\n  - [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)\n  - [Monitoring domain expiration](#monitoring-domain-expiration)\n  - [Concurrency](#concurrency)\n  - [Reloading configuration on the fly](#reloading-configuration-on-the-fly)\n  - [Endpoint groups](#endpoint-groups)\n  - [How do I sort by group by default?](#how-do-i-sort-by-group-by-default)\n  - [Exposing Gatus on a custom path](#exposing-gatus-on-a-custom-path)\n  - [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)\n  - [Use environment variables in config files](#use-environment-variables-in-config-files)\n  - [Configuring a startup delay](#configuring-a-startup-delay)\n  - [Keeping your configuration small](#keeping-your-configuration-small)\n  - [Proxy client configuration](#proxy-client-configuration)\n  - [How to fix 431 Request Header Fields Too Large error](#how-to-fix-431-request-header-fields-too-large-error)\n  - [Badges](#badges)\n    - [Uptime](#uptime)\n    - [Health](#health)\n    - [Health (Shields.io)](#health-shieldsio)\n    - [Response time](#response-time)\n    - [Response time (chart)](#response-time-chart)\n      - [How to change the color thresholds of the response time badge](#how-to-change-the-color-thresholds-of-the-response-time-badge)\n  - [API](#api)\n    - [Interacting with the API programmatically](#interacting-with-the-api-programmatically)\n    - [Raw Data](#raw-data)\n      - [Uptime](#uptime-1)\n      - [Response Time](#response-time-1)\n  - [Installing as binary](#installing-as-binary)\n  - [High level design overview](#high-level-design-overview)\n\n\n## Why Gatus?\nBefore getting into the specifics, I want to address the most common question:\n> Why would I use Gatus when I can just use Prometheus’ Alertmanager, Cloudwatch or even Splunk?\n\nNeither of these can tell you that there’s a problem if there are no clients actively calling the endpoint.\nIn other words, it's because monitoring metrics mostly rely on existing traffic, which effectively means that unless\nyour clients are already experiencing a problem, you won't be notified.\n\nGatus, on the other hand, allows you to configure health checks for each of your features, which in turn allows it to\nmonitor these features and potentially alert you before any clients are impacted.\n\nA sign you may want to look into Gatus is by simply asking yourself whether you'd receive an alert if your load balancer\nwas to go down right now. Will any of your existing alerts be triggered? Your metrics won’t report an increase in errors\nif no traffic makes it to your applications. This puts you in a situation where your clients are the ones\nthat will notify you about the degradation of your services rather than you reassuring them that you're working on\nfixing the issue before they even know about it.\n\n\n## Features\nThe main features of Gatus are:\n\n- **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.\n- **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests.\n- **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.\n- **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.\n- **Metrics**\n- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.\n- **[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)\n- **Dark mode**\n\n![Gatus dashboard conditions](.github/assets/dashboard-conditions.jpg)\n\n\n## Usage\n\n```console\ndocker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable\n```\n\nYou can also use Docker Hub if you prefer:\n```console\ndocker run -p 8080:8080 --name gatus twinproduction/gatus:stable\n```\nIf you want to create your own configuration, see [Docker](#docker) for information on how to mount a configuration file.\n\nHere's a simple example:\n```yaml\nendpoints:\n  - name: website                 # Name of your endpoint, can be anything\n    url: \"https://twin.sh/health\"\n    interval: 5m                  # Duration to wait between every status check (default: 60s)\n    conditions:\n      - \"[STATUS] == 200\"         # Status must be 200\n      - \"[BODY].status == UP\"     # The json path \"$.status\" must be equal to UP\n      - \"[RESPONSE_TIME] < 300\"   # Response time must be under 300ms\n\n  - name: make-sure-header-is-rendered\n    url: \"https://example.org/\"\n    interval: 60s\n    conditions:\n      - \"[STATUS] == 200\"                          # Status must be 200\n      - \"[BODY] == pat(*<h1>Example Domain</h1>*)\" # Body must contain the specified header\n```\n\nThis example would look similar to this:\n\n![Simple example](.github/assets/example.jpg)\n\nIf you want to test it locally, see [Docker](#docker).\n\n## Configuration\nBy default, the configuration file is expected to be at `config/config.yaml`.\n\nYou can specify a custom path by setting the `GATUS_CONFIG_PATH` environment variable.\n\nIf `GATUS_CONFIG_PATH` points to a directory, all `*.yaml` and `*.yml` files inside said directory and its\nsubdirectories are merged like so:\n- All maps/objects are deep merged (i.e. you could define `alerting.slack` in one file and `alerting.pagerduty` in another file)\n- 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)\n- Parameters with a primitive value (e.g. `metrics`, `alerting.slack.webhook-url`, etc.) may only be defined once to forcefully avoid any ambiguity\n    - 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.\n\n> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)\n>\n> ⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.\n>\n> 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.\n\nIf you want to test it locally, see [Docker](#docker).\n\n\n## Configuration\n| Parameter                    | Description                                                                                                                              | Default       |\n|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------------|\n| `metrics`                    | Whether to expose metrics at `/metrics`.                                                                                                 | `false`       |\n| `storage`                    | [Storage configuration](#storage).                                                                                                       | `{}`          |\n| `alerting`                   | [Alerting configuration](#alerting).                                                                                                     | `{}`          |\n| `announcements`              | [Announcements configuration](#announcements).                                                                                           | `[]`          |\n| `endpoints`                  | [Endpoints configuration](#endpoints).                                                                                                   | Required `[]` |\n| `external-endpoints`         | [External Endpoints configuration](#external-endpoints).                                                                                 | `[]`          |\n| `security`                   | [Security configuration](#security).                                                                                                     | `{}`          |\n| `concurrency`                | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency).                   | `3`           |\n| `disable-monitoring-lock`    | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead.                        | `false`       |\n| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly).     | `false`       |\n| `web`                        | [Web configuration](#web).                                                                                                               | `{}`          |\n| `ui`                         | [UI configuration](#ui).                                                                                                                 | `{}`          |\n| `maintenance`                | [Maintenance configuration](#maintenance).                                                                                               | `{}`          |\n\nIf you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.\nConversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.\nThe default value for `GATUS_LOG_LEVEL` is `INFO`.\n\n### Endpoints\nEndpoints are URLs, applications, or services that you want to monitor. Each endpoint has a list of conditions that are\nevaluated on an interval that you define. If any condition fails, the endpoint is considered as unhealthy.\nYou can then configure alerts to be triggered when an endpoint is unhealthy once a certain threshold is reached.\n\n| Parameter                                       | Description                                                                                                                                 | Default                    |\n|:------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|\n| `endpoints`                                     | List of endpoints to monitor.                                                                                                               | Required `[]`              |\n| `endpoints[].enabled`                           | Whether to monitor the endpoint.                                                                                                            | `true`                     |\n| `endpoints[].name`                              | Name of the endpoint. Can be anything.                                                                                                      | Required `\"\"`              |\n| `endpoints[].group`                             | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups).                      | `\"\"`                       |\n| `endpoints[].url`                               | URL to send the request to.                                                                                                                 | Required `\"\"`              |\n| `endpoints[].method`                            | Request method.                                                                                                                             | `GET`                      |\n| `endpoints[].conditions`                        | Conditions used to determine the health of the endpoint. <br />See [Conditions](#conditions).                                               | `[]`                       |\n| `endpoints[].interval`                          | Duration to wait between every status check.                                                                                                | `60s`                      |\n| `endpoints[].graphql`                           | Whether to wrap the body in a query param (`{\"query\":\"$body\"}`).                                                                            | `false`                    |\n| `endpoints[].body`                              | Request body.                                                                                                                               | `\"\"`                       |\n| `endpoints[].headers`                           | Request headers.                                                                                                                            | `{}`                       |\n| `endpoints[].dns`                               | Configuration for an endpoint of type DNS. <br />See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `\"\"`                       |\n| `endpoints[].dns.query-type`                    | Query type (e.g. MX).                                                                                                                       | `\"\"`                       |\n| `endpoints[].dns.query-name`                    | Query name (e.g. example.com).                                                                                                              | `\"\"`                       |\n| `endpoints[].ssh`                               | Configuration for an endpoint of type SSH. <br />See [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh).                 | `\"\"`                       |\n| `endpoints[].ssh.username`                      | SSH username (e.g. example).                                                                                                                | Required `\"\"`              |\n| `endpoints[].ssh.password`                      | SSH password (e.g. password).                                                                                                               | Required `\"\"`              |\n| `endpoints[].alerts`                            | List of all alerts for a given endpoint. <br />See [Alerting](#alerting).                                                                   | `[]`                       |\n| `endpoints[].maintenance-windows`               | List of all maintenance windows for a given endpoint. <br />See [Maintenance](#maintenance).                                                | `[]`                       |\n| `endpoints[].client`                            | [Client configuration](#client-configuration).                                                                                              | `{}`                       |\n| `endpoints[].ui`                                | UI configuration at the endpoint level.                                                                                                     | `{}`                       |\n| `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`                    |\n| `endpoints[].ui.hide-hostname`                  | Whether to hide the hostname from the results.                                                                                              | `false`                    |\n| `endpoints[].ui.hide-port`                      | Whether to hide the port from the results.                                                                                                  | `false`                    |\n| `endpoints[].ui.hide-url`                       | Whether to hide the URL from the results. Useful if the URL contains a token.                                                               | `false`                    |\n| `endpoints[].ui.hide-errors`                    | Whether to hide errors from the results.                                                                                                    | `false`                    |\n| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI.                                                                                            | `false`                    |\n| `endpoints[].ui.resolve-successful-conditions`  | Whether to resolve successful conditions for the UI (helpful to expose body assertions even when checks pass).                              | `false`                    |\n| `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]` |\n| `endpoints[].extra-labels`                      | Extra labels to add to the metrics. Useful for grouping endpoints together.                                                                 | `{}`                       |\n| `endpoints[].always-run`                        | (SUITES ONLY) Whether to execute this endpoint even if previous endpoints in the suite failed.                                              | `false`                    |\n| `endpoints[].store`                             | (SUITES ONLY) Map of values to extract from the response and store in the suite context (stored even on failure).                           | `{}`                       |\n\nYou may use the following placeholders in the body (`endpoints[].body`):\n- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)\n- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)\n- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)\n- `[LOCAL_ADDRESS]` (resolves to the local IP and port like `192.0.2.1:25` or `[2001:db8::1]:80`)\n- `[RANDOM_STRING_N]` (resolves to a random string of numbers and letters of length N (max: 8192))\n\n### External Endpoints\nUnlike regular endpoints, external endpoints are not monitored by Gatus, but they are instead pushed programmatically.\nThis 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.\n\nFor instance:\n- 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\n- You can monitor services that are not supported by Gatus\n- You can implement your own monitoring system while using Gatus as the dashboard\n\n| Parameter                                 | Description                                                                                                                       | Default        |\n|:------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------|:---------------|\n| `external-endpoints`                      | List of endpoints to monitor.                                                                                                     | `[]`           |\n| `external-endpoints[].enabled`            | Whether to monitor the endpoint.                                                                                                  | `true`         |\n| `external-endpoints[].name`               | Name of the endpoint. Can be anything.                                                                                            | Required `\"\"`  |\n| `external-endpoints[].group`              | Group name. Used to group multiple endpoints together on the dashboard. <br />See [Endpoint groups](#endpoint-groups).            | `\"\"`           |\n| `external-endpoints[].token`              | Bearer token required to push status to.                                                                                          | Required `\"\"`  |\n| `external-endpoints[].alerts`             | List of all alerts for a given endpoint. <br />See [Alerting](#alerting).                                                         | `[]`           |\n| `external-endpoints[].heartbeat`          | Heartbeat configuration for monitoring when the external endpoint stops sending updates.                                          | `{}`           |\n| `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) |\n\nExample:\n```yaml\nexternal-endpoints:\n  - name: ext-ep-test\n    group: core\n    token: \"potato\"\n    heartbeat:\n      interval: 30m  # Automatically create a failure if no update is received within 30 minutes\n    alerts:\n      - type: discord\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\nTo push the status of an external endpoint, you can use [gatus-cli](https://github.com/TwiN/gatus-cli):\n```\ngatus-cli external-endpoint push --url https://status.example.org --key \"core_ext-ep-test\" --token \"potato\" --success\n```\n\nor send an HTTP request:\n```\nPOST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}\n```\nWhere:\n- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.\n  - Using the example configuration above, the key would be `core_ext-ep-test`.\n- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.\n- `{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.\n- `{duration}` (optional): the time that the request took as a duration string (e.g. 10s).\n\nYou must also pass the token as a `Bearer` token in the `Authorization` header.\n\n\n### Suites (ALPHA)\nSuites are collections of endpoints that are executed sequentially with a shared context.\nThis allows you to create complex monitoring scenarios where the result from one endpoint can be used in subsequent endpoints, enabling workflow-style monitoring.\n\nHere are a few cases in which suites could be useful:\n- Testing multi-step authentication flows (login -> access protected resource -> logout)\n- API workflows where you need to chain requests (create resource -> update -> verify -> delete)\n- Monitoring business processes that span multiple services\n- Validating data consistency across multiple endpoints\n\n| Parameter                         | Description                                                                                         | Default       |\n|:----------------------------------|:----------------------------------------------------------------------------------------------------|:--------------|\n| `suites`                          | List of suites to monitor.                                                                          | `[]`          |\n| `suites[].enabled`                | Whether to monitor the suite.                                                                       | `true`        |\n| `suites[].name`                   | Name of the suite. Must be unique.                                                                  | Required `\"\"` |\n| `suites[].group`                  | Group name. Used to group multiple suites together on the dashboard.                                | `\"\"`          |\n| `suites[].interval`               | Duration to wait between suite executions.                                                          | `10m`         |\n| `suites[].timeout`                | Maximum duration for the entire suite execution.                                                    | `5m`          |\n| `suites[].context`                | Initial context values that can be referenced by endpoints.                                         | `{}`          |\n| `suites[].endpoints`              | List of endpoints to execute sequentially.                                                          | Required `[]` |\n| `suites[].endpoints[].store`      | Map of values to extract from the response and store in the suite context (stored even on failure). | `{}`          |\n| `suites[].endpoints[].always-run` | Whether to execute this endpoint even if previous endpoints in the suite failed.                    | `false`       |\n\n**Note**: Suite-level alerts are not supported yet. Configure alerts on individual endpoints within the suite instead.\n\n#### Using Context in Endpoints\nOnce values are stored in the context, they can be referenced in subsequent endpoints:\n- In the URL: `https://api.example.com/users/[CONTEXT].user_id`\n- In headers: `Authorization: Bearer [CONTEXT].auth_token`\n- In the body: `{\"user_id\": \"[CONTEXT].user_id\"}`\n- In conditions: `[BODY].server_ip == [CONTEXT].server_ip`\n\nNote that context/store keys are limited to A-Z, a-z, 0-9, underscores (`_`), and hyphens (`-`).\n\n#### Example Suite Configuration\n```yaml\nsuites:\n  - name: item-crud-workflow\n    group: api-tests\n    interval: 5m\n    context:\n      price: \"19.99\"  # Initial static value in context\n    endpoints:\n      # Step 1: Create an item and store the item ID\n      - name: create-item\n        url: https://api.example.com/items\n        method: POST\n        body: '{\"name\": \"Test Item\", \"price\": \"[CONTEXT].price\"}'\n        conditions:\n          - \"[STATUS] == 201\"\n          - \"len([BODY].id) > 0\"\n          - \"[BODY].price == [CONTEXT].price\"\n        store:\n          itemId: \"[BODY].id\"\n        alerts:\n          - type: slack\n            description: \"Failed to create item\"\n\n      # Step 2: Update the item using the stored item ID\n      - name: update-item\n        url: https://api.example.com/items/[CONTEXT].itemId\n        method: PUT\n        body: '{\"price\": \"24.99\"}'\n        conditions:\n          - \"[STATUS] == 200\"\n        alerts:\n          - type: slack\n            description: \"Failed to update item\"\n\n      # Step 3: Fetch the item and validate the price\n      - name: get-item\n        url: https://api.example.com/items/[CONTEXT].itemId\n        method: GET\n        conditions:\n          - \"[STATUS] == 200\"\n          - \"[BODY].price == 24.99\"\n        alerts:\n          - type: slack\n            description: \"Item price did not update correctly\"\n\n      # Step 4: Delete the item (always-run: true to ensure cleanup even if step 2 or 3 fails)\n      - name: delete-item\n        url: https://api.example.com/items/[CONTEXT].itemId\n        method: DELETE\n        always-run: true\n        conditions:\n          - \"[STATUS] == 204\"\n        alerts:\n          - type: slack\n            description: \"Failed to delete item\"\n```\n\nThe suite will be considered successful only if all required endpoints pass their conditions.\n\n\n### Conditions\nHere are some examples of conditions you can use:\n\n| Condition                        | Description                                         | Passing values             | Failing values   |\n|:---------------------------------|:----------------------------------------------------|:---------------------------|------------------|\n| `[STATUS] == 200`                | Status must be equal to 200                         | 200                        | 201, 404, ...    |\n| `[STATUS] < 300`                 | Status must lower than 300                          | 200, 201, 299              | 301, 302, ...    |\n| `[STATUS] <= 299`                | Status must be less than or equal to 299            | 200, 201, 299              | 301, 302, ...    |\n| `[STATUS] > 400`                 | Status must be greater than 400                     | 401, 402, 403, 404         | 400, 200, ...    |\n| `[STATUS] == any(200, 429)`      | Status must be either 200 or 429                    | 200, 429                   | 201, 400, ...    |\n| `[CONNECTED] == true`            | Connection to host must've been successful          | true                       | false            |\n| `[RESPONSE_TIME] < 500`          | Response time must be below 500ms                   | 100ms, 200ms, 300ms        | 500ms, 501ms     |\n| `[IP] == 127.0.0.1`              | Target IP must be 127.0.0.1                         | 127.0.0.1                  | 0.0.0.0          |\n| `[BODY] == 1`                    | The body must be equal to 1                         | 1                          | `{}`, `2`, ...   |\n| `[BODY].user.name == john`       | JSONPath value of `$.user.name` is equal to `john`  | `{\"user\":{\"name\":\"john\"}}` |                  |\n| `[BODY].data[0].id == 1`         | JSONPath value of `$.data[0].id` is equal to 1      | `{\"data\":[{\"id\":1}]}`      |                  |\n| `[BODY].age == [BODY].id`        | JSONPath value of `$.age` is equal JSONPath `$.id`  | `{\"age\":1,\"id\":1}`         |                  |\n| `len([BODY].data) < 5`           | Array at JSONPath `$.data` has less than 5 elements | `{\"data\":[{\"id\":1}]}`      |                  |\n| `len([BODY].name) == 8`          | String at JSONPath `$.name` has a length of 8       | `{\"name\":\"john.doe\"}`      | `{\"name\":\"bob\"}` |\n| `has([BODY].errors) == false`    | JSONPath `$.errors` does not exist                  | `{\"name\":\"john.doe\"}`      | `{\"errors\":[]}`  |\n| `has([BODY].users) == true`      | JSONPath `$.users` exists                           | `{\"users\":[]}`             | `{}`             |\n| `[BODY].name == pat(john*)`      | String at JSONPath `$.name` matches pattern `john*` | `{\"name\":\"john.doe\"}`      | `{\"name\":\"bob\"}` |\n| `[BODY].id == any(1, 2)`         | Value at JSONPath `$.id` is equal to `1` or `2`     | 1, 2                       | 3, 4, 5          |\n| `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away        | 49h, 50h, 123h             | 1h, 24h, ...     |\n| `[DOMAIN_EXPIRATION] > 720h`     | The domain must expire in more than 720h            | 4000h                      | 1h, 24h, ...     |\n\n\n#### Placeholders\n| Placeholder                | Description                                                                               | Example of resolved value                    |\n|:---------------------------|:------------------------------------------------------------------------------------------|:---------------------------------------------|\n| `[STATUS]`                 | Resolves into the HTTP status of the request                                              | `404`                                        |\n| `[RESPONSE_TIME]`          | Resolves into the response time the request took, in ms                                   | `10`                                         |\n| `[IP]`                     | Resolves into the IP of the target host                                                   | `192.168.0.232`                              |\n| `[BODY]`                   | Resolves into the response body. Supports JSONPath.                                       | `{\"name\":\"john.doe\"}`                        |\n| `[CONNECTED]`              | Resolves into whether a connection could be established                                   | `true`                                       |\n| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration (valid units are \"s\", \"m\", \"h\".) | `24h`, `48h`, 0 (if not protocol with certs) |\n| `[DOMAIN_EXPIRATION]`      | Resolves into the duration before the domain expires (valid units are \"s\", \"m\", \"h\".)     | `24h`, `48h`, `1234h56m78s`                  |\n| `[DNS_RCODE]`              | Resolves into the DNS status of the response                                              | `NOERROR`                                    |\n\n\n#### Functions\n| Function | Description                                                                                                                                                                                                                         | Example                            |\n|:---------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------|\n| `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`         |\n| `has`    | Returns `true` or `false` based on whether a given path is valid. Works only with the `[BODY]` placeholder.                                                                                                                         | `has([BODY].errors) == false`      |\n| `pat`    | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`.                                                                                                                      | `[IP] == pat(192.168.*)`           |\n| `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)` |\n\n> 💡 Use `pat` only when you need to. `[STATUS] == pat(2*)` is a lot more expensive than `[STATUS] < 300`.\n\n### Web\nAllows you to configure how and where the dashboard is being served.\n\n| Parameter                  | Description                                                                                 | Default   |\n|:---------------------------|:--------------------------------------------------------------------------------------------|:----------|\n| `web`                      | Web configuration                                                                           | `{}`      |\n| `web.address`              | Address to listen on.                                                                       | `0.0.0.0` |\n| `web.port`                 | Port to listen on.                                                                          | `8080`    |\n| `web.read-buffer-size`     | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192`    |\n| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format.                                     | `\"\"`      |\n| `web.tls.private-key-file` | Optional private key file for TLS in PEM format.                                            | `\"\"`      |\n\n### UI\nAllows 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.\n\n| Parameter                 | Description                                                                                                                              | Default                                             |\n|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------|\n| `ui`                      | UI configuration                                                                                                                         | `{}`                                                |\n| `ui.title`                | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title).                                                | `Health Dashboard ǀ Gatus`                          |\n| `ui.description`          | Meta description for the page.                                                                                                           | `Gatus is an advanced...`.                          |\n| `ui.dashboard-heading`    | Dashboard title between header and endpoints                                                                                             | `Health Dashboard`                                  |\n| `ui.dashboard-subheading` | Dashboard description between header and endpoints                                                                                       | `Monitor the health of your endpoints in real-time` |\n| `ui.header`               | Header at the top of the dashboard.                                                                                                      | `Gatus`                                             |\n| `ui.logo`                 | URL to the logo to display.                                                                                                              | `\"\"`                                                |\n| `ui.link`                 | Link to open when the logo is clicked.                                                                                                   | `\"\"`                                                |\n| `ui.favicon.default`      | Favourite default icon to display in web browser tab or address bar.                                                                     | `/favicon.ico`                                      |\n| `ui.favicon.size16x16`    | Favourite icon to display in web browser for 16x16 size.                                                                                 | `/favicon-16x16.png`                                |\n| `ui.favicon.size32x32`    | Favourite icon to display in web browser for 32x32 size.                                                                                 | `/favicon-32x32.png`                                |\n| `ui.buttons`              | List of buttons to display below the header.                                                                                             | `[]`                                                |\n| `ui.buttons[].name`       | Text to display on the button.                                                                                                           | Required `\"\"`                                       |\n| `ui.buttons[].link`       | Link to open when the button is clicked.                                                                                                 | Required `\"\"`                                       |\n| `ui.custom-css`           | Custom CSS                                                                                                                               | `\"\"`                                                |\n| `ui.dark-mode`            | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences.                   | `true`                                              |\n| `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`                                              |\n| `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`                                              |\n\n### Announcements\nSystem-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.\n\nThis is essentially what some status page calls \"incident communications\".\n\n| Parameter                   | Description                                                                                                              | Default  |\n|:----------------------------|:-------------------------------------------------------------------------------------------------------------------------|:---------|\n| `announcements`             | List of announcements to display                                                                                         | `[]`     |\n| `announcements[].timestamp` | UTC timestamp when the announcement was made (RFC3339 format)                                                            | Required |\n| `announcements[].type`      | Type of announcement. Valid values: `outage`, `warning`, `information`, `operational`, `none`                            | `\"none\"` |\n| `announcements[].message`   | The message to display to users                                                                                          | Required |\n| `announcements[].archived`  | Whether to archive the announcement. Archived announcements show at the bottom of the status page instead of at the top. | `false`  |\n\nTypes:\n- **outage**: Indicates service disruptions or critical issues (red theme)\n- **warning**: Indicates potential issues or important notices (yellow theme)\n- **information**: General information or updates (blue theme)\n- **operational**: Indicates resolved issues or normal operations (green theme)\n- **none**: Neutral announcements with no specific severity (gray theme, default if none are specified)\n\nExample Configuration:\n```yaml\nannouncements:\n  - timestamp: 2025-11-07T14:00:00Z\n    type: outage\n    message: \"Scheduled maintenance on database servers from 14:00 to 16:00 UTC\"\n  - timestamp: 2025-11-07T16:15:00Z\n    type: operational\n    message: \"Database maintenance completed successfully. All systems operational.\"\n  - timestamp: 2025-11-07T12:00:00Z\n    type: information\n    message: \"New monitoring dashboard features will be deployed next week\"\n  - timestamp: 2025-11-06T09:00:00Z\n    type: warning\n    message: \"Elevated API response times observed for US customers\"\n    archived: true\n```\n\nIf at least one announcement is archived, a **Past Announcements** section will be rendered at the bottom of the status page:\n![Gatus past announcements section](.github/assets/past-announcements.jpg)\n\n\n### Storage\n| Parameter                           | Description                                                                                                                                        | Default    |\n|:------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|\n| `storage`                           | Storage configuration                                                                                                                              | `{}`       |\n| `storage.path`                      | Path to persist the data in. Only supported for types `sqlite` and `postgres`.                                                                     | `\"\"`       |\n| `storage.type`                      | Type of storage. Valid types: `memory`, `sqlite`, `postgres`.                                                                                      | `\"memory\"` |\n| `storage.caching`                   | Whether to use write-through caching. Improves loading time for large dashboards. <br />Only supported if `storage.type` is `sqlite` or `postgres` | `false`    |\n| `storage.maximum-number-of-results` | The maximum number of results that an endpoint can have                                                                                            | `100`      |\n| `storage.maximum-number-of-events`  | The maximum number of events that an endpoint can have                                                                                             | `50`       |\n\nThe results for each endpoint health check as well as the data for uptime and the past events must be persisted\nso that they can be displayed on the dashboard. These parameters allow you to configure the storage in question.\n\n- If `storage.type` is `memory` (default):\n```yaml\n# Note that this is the default value, and you can omit the storage configuration altogether to achieve the same result.\n# Because the data is stored in memory, the data will not survive a restart.\nstorage:\n  type: memory\n  maximum-number-of-results: 200\n  maximum-number-of-events: 5\n```\n- If `storage.type` is `sqlite`, `storage.path` must not be blank:\n```yaml\nstorage:\n  type: sqlite\n  path: data.db\n```\nSee [examples/docker-compose-sqlite-storage](.examples/docker-compose-sqlite-storage) for an example.\n\n- If `storage.type` is `postgres`, `storage.path` must be the connection URL:\n```yaml\nstorage:\n  type: postgres\n  path: \"postgres://user:password@127.0.0.1:5432/gatus?sslmode=disable\"\n```\nSee [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres-storage) for an example.\n\n\n### Client configuration\nIn order to support a wide range of environments, each monitored endpoint has a unique configuration for\nthe client used to send the request.\n\n| Parameter                              | Description                                                                   | Default         |\n|:---------------------------------------|:------------------------------------------------------------------------------|:----------------|\n| `client.insecure`                      | Whether to skip verifying the server's certificate chain and host name.       | `false`         |\n| `client.ignore-redirect`               | Whether to ignore redirects (true) or follow them (false, default).           | `false`         |\n| `client.timeout`                       | Duration before timing out.                                                   | `10s`           |\n| `client.dns-resolver`                  | Override the DNS resolver using the format `{proto}://{host}:{port}`.         | `\"\"`            |\n| `client.oauth2`                        | OAuth2 client configuration.                                                  | `{}`            |\n| `client.oauth2.token-url`              | The token endpoint URL                                                        | required `\"\"`   |\n| `client.oauth2.client-id`              | The client id which should be used for the `Client credentials flow`          | required `\"\"`   |\n| `client.oauth2.client-secret`          | The client secret which should be used for the `Client credentials flow`      | required `\"\"`   |\n| `client.oauth2.scopes[]`               | A list of `scopes` which should be used for the `Client credentials flow`.    | required `[\"\"]` |\n| `client.proxy-url`                     | The URL of the proxy to use for the client                                    | `\"\"`            |\n| `client.identity-aware-proxy`          | Google Identity-Aware-Proxy client configuration.                             | `{}`            |\n| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential)   | required `\"\"`   |\n| `client.tls.certificate-file`          | Path to a client certificate (in PEM format) for mTLS configurations.         | `\"\"`            |\n| `client.tls.private-key-file`          | Path to a client private key (in PEM format) for mTLS configurations.         | `\"\"`            |\n| `client.tls.renegotiation`             | Type of renegotiation support to provide. (`never`, `freely`, `once`).        | `\"never\"`       |\n| `client.network`                       | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`).           | `\"ip\"`          |\n| `client.tunnel`                        | Name of the SSH tunnel to use for this endpoint. See [Tunneling](#tunneling). | `\"\"`            |\n\n\n> 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved\n> in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything.\n\nThis default configuration is as follows:\n\n```yaml\nclient:\n  insecure: false\n  ignore-redirect: false\n  timeout: 10s\n```\n\nNote that this configuration is only available under `endpoints[]`, `alerting.mattermost` and `alerting.custom`.\n\nHere's an example with the client configuration under `endpoints[]`:\n\n```yaml\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    client:\n      insecure: false\n      ignore-redirect: false\n      timeout: 10s\n    conditions:\n      - \"[STATUS] == 200\"\n```\n\nThis example shows how you can specify a custom DNS resolver:\n\n```yaml\nendpoints:\n  - name: with-custom-dns-resolver\n    url: \"https://your.health.api/health\"\n    client:\n      dns-resolver: \"tcp://8.8.8.8:53\"\n    conditions:\n      - \"[STATUS] == 200\"\n```\n\nThis example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`:\n\n```yaml\nendpoints:\n  - name: with-custom-oauth2\n    url: \"https://your.health.api/health\"\n    client:\n      oauth2:\n        token-url: https://your-token-server/token\n        client-id: 00000000-0000-0000-0000-000000000000\n        client-secret: your-client-secret\n        scopes: ['https://your.health.api/.default']\n    conditions:\n      - \"[STATUS] == 200\"\n```\n\nThis 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:\n\n```yaml\nendpoints:\n  - name: with-custom-iap\n    url: \"https://my.iap.protected.app/health\"\n    client:\n      identity-aware-proxy:\n        audience: \"XXXXXXXX-XXXXXXXXXXXX.apps.googleusercontent.com\"\n    conditions:\n      - \"[STATUS] == 200\"\n```\n\n> 📝 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.\n\nThis example shows you how you can use the `client.tls` configuration to perform an mTLS query to a backend API:\n\n```yaml\nendpoints:\n  - name: website\n    url: \"https://your.mtls.protected.app/health\"\n    client:\n      tls:\n        certificate-file: /path/to/user_cert.pem\n        private-key-file: /path/to/user_key.pem\n        renegotiation: once\n    conditions:\n      - \"[STATUS] == 200\"\n```\n\n> 📝 Note that if running in a container, you must volume mount the certificate and key into the container.\n\n### Tunneling\nGatus supports SSH tunneling to monitor internal services through jump hosts or bastion servers.\nThis is particularly useful for monitoring services that are not directly accessible from where Gatus is deployed.\n\nSSH tunnels are defined globally in the `tunneling` section and then referenced by name in endpoint client configurations.\n\n| Parameter                             | Description                                                 | Default       |\n|:--------------------------------------|:------------------------------------------------------------|:--------------|\n| `tunneling`                           | SSH tunnel configurations                                   | `{}`          |\n| `tunneling.<tunnel-name>`             | Configuration for a named SSH tunnel                        | `{}`          |\n| `tunneling.<tunnel-name>.type`        | Type of tunnel (currently only `SSH` is supported)          | Required `\"\"` |\n| `tunneling.<tunnel-name>.host`        | SSH server hostname or IP address                           | Required `\"\"` |\n| `tunneling.<tunnel-name>.port`        | SSH server port                                             | `22`          |\n| `tunneling.<tunnel-name>.username`    | SSH username                                                | Required `\"\"` |\n| `tunneling.<tunnel-name>.password`    | SSH password (use either this or private-key)               | `\"\"`          |\n| `tunneling.<tunnel-name>.private-key` | SSH private key in PEM format (use either this or password) | `\"\"`          |\n| `client.tunnel`                       | Name of the tunnel to use for this endpoint                 | `\"\"`          |\n\n```yaml\ntunneling:\n  production:\n    type: SSH\n    host: \"jumphost.example.com\"\n    username: \"monitoring\"\n    private-key: |\n      -----BEGIN RSA PRIVATE KEY-----\n      MIIEpAIBAAKCAQEA...\n      -----END RSA PRIVATE KEY-----\n\nendpoints:\n  - name: \"internal-api\"\n    url: \"http://internal-api.example.com:8080/health\"\n    client:\n      tunnel: \"production\"\n    conditions:\n      - \"[STATUS] == 200\"\n```\n\n> ⚠️ **WARNING**:: Tunneling may introduce additional latency, especially if the connection to the tunnel is retried frequently.\n> This may lead to inaccurate response time measurements.\n\n\n### Alerting\nGatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each\nindividual endpoints with configurable descriptions and thresholds.\n\nAlerts are configured at the endpoint level like so:\n\n| Parameter                            | Description                                                                                                                                               | Default       |\n|:-------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------|\n| `alerts`                             | List of all alerts for a given endpoint.                                                                                                                  | `[]`          |\n| `alerts[].type`                      | Type of alert. <br />See table below for all valid types.                                                                                                 | Required `\"\"` |\n| `alerts[].enabled`                   | Whether to enable the alert.                                                                                                                              | `true`        |\n| `alerts[].failure-threshold`         | Number of failures in a row needed before triggering the alert.                                                                                           | `3`           |\n| `alerts[].success-threshold`         | Number of successes in a row before an ongoing incident is marked as resolved.                                                                            | `2`           |\n| `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`           |\n| `alerts[].send-on-resolved`          | Whether to send a notification once a triggered alert is marked as resolved.                                                                              | `false`       |\n| `alerts[].description`               | Description of the alert. Will be included in the alert sent.                                                                                             | `\"\"`          |\n| `alerts[].provider-override`         | Alerting provider configuration override for the given alert type                                                                                         | `{}`          |\n\nHere's an example of what an alert configuration might look like at the endpoint level:\n```yaml\nendpoints:\n  - name: example\n    url: \"https://example.org\"\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: slack\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\nYou can also override global provider configuration by using `alerts[].provider-override`, like so:\n```yaml\nendpoints:\n  - name: example\n    url: \"https://example.org\"\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: slack\n        provider-override:\n          webhook-url: \"https://hooks.slack.com/services/**********/**********/**********\"\n```\n\n> 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be\n> ignored.\n\n| Parameter                  | Description                                                                                                                             | Default |\n|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|\n| `alerting.awsses`          | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts).                         | `{}`    |\n| `alerting.clickup`         | Configuration for alerts of type `clickup`. <br />See [Configuring ClickUp alerts](#configuring-clickup-alerts).                        | `{}`    |\n| `alerting.custom`          | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts).               | `{}`    |\n| `alerting.datadog`         | Configuration for alerts of type `datadog`. <br />See [Configuring Datadog alerts](#configuring-datadog-alerts).                        | `{}`    |\n| `alerting.discord`         | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts).                        | `{}`    |\n| `alerting.email`           | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts).                              | `{}`    |\n| `alerting.gitea`           | Configuration for alerts of type `gitea`. <br />See [Configuring Gitea alerts](#configuring-gitea-alerts).                              | `{}`    |\n| `alerting.github`          | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts).                           | `{}`    |\n| `alerting.gitlab`          | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts).                           | `{}`    |\n| `alerting.googlechat`      | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts).             | `{}`    |\n| `alerting.gotify`          | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts).                           | `{}`    |\n| `alerting.homeassistant`   | Configuration for alerts of type `homeassistant`. <br />See [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts).      | `{}`    |\n| `alerting.ifttt`           | Configuration for alerts of type `ifttt`. <br />See [Configuring IFTTT alerts](#configuring-ifttt-alerts).                              | `{}`    |\n| `alerting.ilert`           | Configuration for alerts of type `ilert`. <br />See [Configuring ilert alerts](#configuring-ilert-alerts).                              | `{}`    |\n| `alerting.incident-io`     | Configuration for alerts of type `incident-io`. <br />See [Configuring Incident.io alerts](#configuring-incidentio-alerts).             | `{}`    |\n| `alerting.line`            | Configuration for alerts of type `line`. <br />See [Configuring Line alerts](#configuring-line-alerts).                                 | `{}`    |\n| `alerting.matrix`          | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts).                           | `{}`    |\n| `alerting.mattermost`      | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts).               | `{}`    |\n| `alerting.messagebird`     | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts).            | `{}`    |\n| `alerting.n8n`             | Configuration for alerts of type `n8n`. <br />See [Configuring n8n alerts](#configuring-n8n-alerts).                                    | `{}`    |\n| `alerting.newrelic`        | Configuration for alerts of type `newrelic`. <br />See [Configuring New Relic alerts](#configuring-new-relic-alerts).                   | `{}`    |\n| `alerting.ntfy`            | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts).                                 | `{}`    |\n| `alerting.opsgenie`        | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts).                     | `{}`    |\n| `alerting.pagerduty`       | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts).                  | `{}`    |\n| `alerting.plivo`           | Configuration for alerts of type `plivo`. <br />See [Configuring Plivo alerts](#configuring-plivo-alerts).                              | `{}`    |\n| `alerting.pushover`        | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts).                     | `{}`    |\n| `alerting.rocketchat`      | Configuration for alerts of type `rocketchat`. <br />See [Configuring Rocket.Chat alerts](#configuring-rocketchat-alerts).              | `{}`    |\n| `alerting.sendgrid`        | Configuration for alerts of type `sendgrid`. <br />See [Configuring SendGrid alerts](#configuring-sendgrid-alerts).                     | `{}`    |\n| `alerting.signal`          | Configuration for alerts of type `signal`. <br />See [Configuring Signal alerts](#configuring-signal-alerts).                           | `{}`    |\n| `alerting.signl4`          | Configuration for alerts of type `signl4`. <br />See [Configuring SIGNL4 alerts](#configuring-signl4-alerts).                           | `{}`    |\n| `alerting.slack`           | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts).                              | `{}`    |\n| `alerting.splunk`          | Configuration for alerts of type `splunk`. <br />See [Configuring Splunk alerts](#configuring-splunk-alerts).                           | `{}`    |\n| `alerting.squadcast`       | Configuration for alerts of type `squadcast`. <br />See [Configuring Squadcast alerts](#configuring-squadcast-alerts).                  | `{}`    |\n| `alerting.teams`           | Configuration for alerts of type `teams`. *(Deprecated)* <br />See [Configuring Teams alerts](#configuring-teams-alerts-deprecated).    | `{}`    |\n| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. <br />See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts).  | `{}`    |\n| `alerting.telegram`        | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts).                     | `{}`    |\n| `alerting.twilio`          | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts).                                | `{}`    |\n| `alerting.vonage`          | Configuration for alerts of type `vonage`. <br />See [Configuring Vonage alerts](#configuring-vonage-alerts).                           | `{}`    |\n| `alerting.webex`           | Configuration for alerts of type `webex`. <br />See [Configuring Webex alerts](#configuring-webex-alerts).                              | `{}`    |\n| `alerting.zapier`          | Configuration for alerts of type `zapier`. <br />See [Configuring Zapier alerts](#configuring-zapier-alerts).                           | `{}`    |\n| `alerting.zulip`           | Configuration for alerts of type `zulip`. <br />See [Configuring Zulip alerts](#configuring-zulip-alerts).                              | `{}`    |\n\n\n#### Configuring AWS SES alerts\n| Parameter                            | Description                                                                                | Default       |\n|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.aws-ses`                   | Settings for alerts of type `aws-ses`                                                      | `{}`          |\n| `alerting.aws-ses.access-key-id`     | AWS Access Key ID                                                                          | Optional `\"\"` |\n| `alerting.aws-ses.secret-access-key` | AWS Secret Access Key                                                                      | Optional `\"\"` |\n| `alerting.aws-ses.region`            | AWS Region                                                                                 | Required `\"\"` |\n| `alerting.aws-ses.from`              | The Email address to send the emails from (should be registered in SES)                    | Required `\"\"` |\n| `alerting.aws-ses.to`                | Comma separated list of email address to notify                                            | Required `\"\"` |\n| `alerting.aws-ses.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.aws-ses.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.aws-ses.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.aws-ses.overrides[].*`     | See `alerting.aws-ses.*` parameters                                                        | `{}`          |\n\n```yaml\nalerting:\n  aws-ses:\n    access-key-id: \"...\"\n    secret-access-key: \"...\"\n    region: \"us-east-1\"\n    from: \"status@example.com\"\n    to: \"user@example.com\"\n\nendpoints:\n  - name: website\n    interval: 30s\n    url: \"https://twin.sh/health\"\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: aws-ses\n        failure-threshold: 5\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n```\n\nIf the `access-key-id` and `secret-access-key` are not defined Gatus will fall back to IAM authentication.\n\nMake sure you have the ability to use `ses:SendEmail`.\n\n\n#### Configuring ClickUp alerts\n\n| Parameter                          | Description                                                                                | Default       |\n| :--------------------------------- | :----------------------------------------------------------------------------------------- | :------------ |\n| `alerting.clickup`                 | Configuration for alerts of type `clickup`                                                 | `{}`          |\n| `alerting.clickup.list-id`         | ClickUp List ID where tasks will be created                                                | Required `\"\"` |\n| `alerting.clickup.token`           | ClickUp API token                                                                          | Required `\"\"` |\n| `alerting.clickup.api-url`         | Custom API URL                   | `https://api.clickup.com/api/v2`          |\n| `alerting.clickup.assignees`       | List of user IDs to assign tasks to                                                        | `[]`          |\n| `alerting.clickup.status`          | Initial status for created tasks                                                           | `\"\"`          |\n| `alerting.clickup.priority`        | Priority level: `urgent`, `high`, `normal`, `low`, or `none`                               | `normal`      |\n| `alerting.clickup.notify-all`      | Whether to notify all assignees when task is created                                       | `true`        |\n| `alerting.clickup.name`            | Custom task name template (supports placeholders)                                          | `Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]`          |\n| `alerting.clickup.content`         | Custom task content template (supports placeholders)                                       | `Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]`          |\n| `alerting.clickup.default-alert`   | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.clickup.overrides`       | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.clickup.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration      | `\"\"`          |\n| `alerting.clickup.overrides[].*`   | See `alerting.clickup.*` parameters                                                        | `{}`          |\n\nThe 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.\n\nThe following placeholders are supported in `name` and `content`:\n\n-   `[ENDPOINT_GROUP]` - Resolved from `endpoints[].group`\n-   `[ENDPOINT_NAME]` - Resolved from `endpoints[].name`\n-   `[ALERT_DESCRIPTION]` - Resolved from `endpoints[].alerts[].description`\n-   `[RESULT_ERRORS]` - Resolved from the health evaluation errors\n\n```yaml\nalerting:\n  clickup:\n    list-id: \"123456789\"\n    token: \"pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"\n    assignees:\n      - \"12345\"\n      - \"67890\"\n    status: \"in progress\"\n    priority: high\n    name: \"Health Check Alert: [ENDPOINT_GROUP] - [ENDPOINT_NAME]\"\n    content: \"Alert triggered for [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: clickup\n        send-on-resolved: true\n```\n\nTo 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)\n\nTo find your List ID:\n\n1. Open the ClickUp list where you want tasks to be created\n2. The List ID is in the URL: `https://app.clickup.com/{workspace_id}/v/l/li/{list_id}`\n\nTo find Assignee IDs:\n\n1. Go to `https://app.clickup.com/{workspace_id}/teams-pulse/teams/people`\n2. Hover over a team member\n3. Click the 3 dots (overflow menu)\n3. Click `Copy member ID`\n\n#### Configuring Datadog alerts\n\n> ⚠️ **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.\n\n| Parameter                            | Description                                                                                | Default           |\n|:-------------------------------------|:-------------------------------------------------------------------------------------------|:------------------|\n| `alerting.datadog`                   | Configuration for alerts of type `datadog`                                                 | `{}`              |\n| `alerting.datadog.api-key`           | Datadog API key                                                                            | Required `\"\"`     |\n| `alerting.datadog.site`              | Datadog site (e.g., datadoghq.com, datadoghq.eu)                                           | `\"datadoghq.com\"` |\n| `alerting.datadog.tags`              | Additional tags to include                                                                 | `[]`              |\n| `alerting.datadog.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A               |\n| `alerting.datadog.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`              |\n| `alerting.datadog.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`              |\n| `alerting.datadog.overrides[].*`     | See `alerting.datadog.*` parameters                                                        | `{}`              |\n\n```yaml\nalerting:\n  datadog:\n    api-key: \"YOUR_API_KEY\"\n    site: \"datadoghq.com\"  # or datadoghq.eu for EU region\n    tags:\n      - \"environment:production\"\n      - \"team:platform\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: datadog\n        send-on-resolved: true\n```\n\n\n#### Configuring Discord alerts\n| Parameter                            | Description                                                                                | Default                             |\n|:-------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|\n| `alerting.discord`                   | Configuration for alerts of type `discord`                                                 | `{}`                                |\n| `alerting.discord.webhook-url`       | Discord Webhook URL                                                                        | Required `\"\"`                       |\n| `alerting.discord.title`             | Title of the notification                                                                  | `\":helmet_with_white_cross: Gatus\"` |\n| `alerting.discord.message-content`   | Message content to send before the embed (useful for pinging users/roles, e.g. `<@123>`)   | `\"\"`                                |\n| `alerting.discord.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A                                 |\n| `alerting.discord.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`                                |\n| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`                                |\n| `alerting.discord.overrides[].*`     | See `alerting.discord.*` parameters                                                        | `{}`                                |\n\n```yaml\nalerting:\n  discord:\n    webhook-url: \"https://discord.com/api/webhooks/**********/**********\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: discord\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\n\n#### Configuring Email alerts\n| Parameter                          | Description                                                                                   | Default       |\n|:-----------------------------------|:----------------------------------------------------------------------------------------------|:--------------|\n| `alerting.email`                   | Configuration for alerts of type `email`                                                      | `{}`          |\n| `alerting.email.from`              | Email used to send the alert                                                                  | Required `\"\"` |\n| `alerting.email.username`          | Username of the SMTP server used to send the alert. If empty, uses `alerting.email.from`.     | `\"\"`          |\n| `alerting.email.password`          | Password of the SMTP server used to send the alert. If empty, no authentication is performed. | `\"\"`          |\n| `alerting.email.host`              | Host of the mail server (e.g. `smtp.gmail.com`)                                               | Required `\"\"` |\n| `alerting.email.port`              | Port the mail server is listening to (e.g. `587`)                                             | Required `0`  |\n| `alerting.email.to`                | Email(s) to send the alerts to                                                                | Required `\"\"` |\n| `alerting.email.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert)    | N/A           |\n| `alerting.email.client.insecure`   | Whether to skip TLS verification                                                              | `false`       |\n| `alerting.email.overrides`         | List of overrides that may be prioritized over the default configuration                      | `[]`          |\n| `alerting.email.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration           | `\"\"`          |\n| `alerting.email.overrides[].*`     | See `alerting.email.*` parameters                                                             | `{}`          |\n\n```yaml\nalerting:\n  email:\n    from: \"from@example.com\"\n    username: \"from@example.com\"\n    password: \"hunter2\"\n    host: \"mail.example.com\"\n    port: 587\n    to: \"recipient1@example.com,recipient2@example.com\"\n    client:\n      insecure: false\n    # You can also add group-specific to keys, which will\n    # override the to key above for the specified groups\n    overrides:\n      - group: \"core\"\n        to: \"recipient3@example.com,recipient4@example.com\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: email\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n\n  - name: back-end\n    group: core\n    url: \"https://example.org/\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[CERTIFICATE_EXPIRATION] > 48h\"\n    alerts:\n      - type: email\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\n> ⚠ Some mail servers are painfully slow.\n\n\n#### Configuring Gitea alerts\n\n| Parameter                       | Description                                                                                                | Default       |\n|:--------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|\n| `alerting.gitea`                | Configuration for alerts of type `gitea`                                                                   | `{}`          |\n| `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`)                                               | Required `\"\"` |\n| `alerting.gitea.token`          | Personal access token to use for authentication. <br />Must have at least RW on issues and RO on metadata. | Required `\"\"` |\n| `alerting.gitea.default-alert`  | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert).                | N/A           |\n\nThe Gitea alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display\nname for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically\nclosed when the alert is resolved.\n\n```yaml\nalerting:\n  gitea:\n    repository-url: \"https://gitea.com/TwiN/test\"\n    token: \"349d63f16......\"\n\nendpoints:\n  - name: example\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 75\"\n    alerts:\n      - type: gitea\n        failure-threshold: 2\n        success-threshold: 3\n        send-on-resolved: true\n        description: \"Everything's burning AAAAAHHHHHHHHHHHHHHH\"\n```\n\n![Gitea alert](.github/assets/gitea-alerts.png)\n\n\n#### Configuring GitHub alerts\n\n| Parameter                        | Description                                                                                                | Default       |\n|:---------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|\n| `alerting.github`                | Configuration for alerts of type `github`                                                                  | `{}`          |\n| `alerting.github.repository-url` | GitHub repository URL (e.g. `https://github.com/TwiN/example`)                                             | Required `\"\"` |\n| `alerting.github.token`          | Personal access token to use for authentication. <br />Must have at least RW on issues and RO on metadata. | Required `\"\"` |\n| `alerting.github.default-alert`  | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert).                | N/A           |\n\nThe GitHub alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display\nname for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically\nclosed when the alert is resolved.\n\n```yaml\nalerting:\n  github:\n    repository-url: \"https://github.com/TwiN/test\"\n    token: \"github_pat_12345...\"\n\nendpoints:\n  - name: example\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 75\"\n    alerts:\n      - type: github\n        failure-threshold: 2\n        success-threshold: 3\n        send-on-resolved: true\n        description: \"Everything's burning AAAAAHHHHHHHHHHHHHHH\"\n```\n\n![GitHub alert](.github/assets/github-alerts.png)\n\n\n#### Configuring GitLab alerts\n| Parameter                           | Description                                                                                                         | Default       |\n|:------------------------------------|:--------------------------------------------------------------------------------------------------------------------|:--------------|\n| `alerting.gitlab`                   | Configuration for alerts of type `gitlab`                                                                           | `{}`          |\n| `alerting.gitlab.webhook-url`       | GitLab alert webhook URL (e.g. `https://gitlab.com/yourusername/example/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json`) | Required `\"\"` |\n| `alerting.gitlab.authorization-key` | GitLab alert authorization key.                                                                                     | Required `\"\"` |\n| `alerting.gitlab.severity`          | Override default severity (critical), can be one of `critical, high, medium, low, info, unknown`                    | `\"\"`          |\n| `alerting.gitlab.monitoring-tool`   | Override the monitoring tool name (gatus)                                                                           | `\"gatus\"`     |\n| `alerting.gitlab.environment-name`  | Set gitlab environment's name. Required to display alerts on a dashboard.                                           | `\"\"`          |\n| `alerting.gitlab.service`           | Override endpoint display name                                                                                      | `\"\"`          |\n| `alerting.gitlab.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert).                         | N/A           |\n\nThe GitLab alerting provider creates an alert prefixed with `alert(gatus):` and suffixed with the endpoint's display\nname for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the alert will be automatically\nclosed when the alert is resolved. See\nhttps://docs.gitlab.com/ee/operations/incident_management/integrations.html#configuration to configure the endpoint.\n\n```yaml\nalerting:\n  gitlab:\n    webhook-url: \"https://gitlab.com/hlidotbe/example/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json\"\n    authorization-key: \"12345\"\n\nendpoints:\n  - name: example\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 75\"\n    alerts:\n      - type: gitlab\n        failure-threshold: 2\n        success-threshold: 3\n        send-on-resolved: true\n        description: \"Everything's burning AAAAAHHHHHHHHHHHHHHH\"\n```\n\n![GitLab alert](.github/assets/gitlab-alerts.png)\n\n\n#### Configuring Google Chat alerts\n| Parameter                               | Description                                                                                 | Default       |\n|:----------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|\n| `alerting.googlechat`                   | Configuration for alerts of type `googlechat`                                               | `{}`          |\n| `alerting.googlechat.webhook-url`       | Google Chat Webhook URL                                                                     | Required `\"\"` |\n| `alerting.googlechat.client`            | Client configuration. <br />See [Client configuration](#client-configuration).              | `{}`          |\n| `alerting.googlechat.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A           |\n| `alerting.googlechat.overrides`         | List of overrides that may be prioritized over the default configuration                    | `[]`          |\n| `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration         | `\"\"`          |\n| `alerting.googlechat.overrides[].*`     | See `alerting.googlechat.*` parameters                                                      | `{}`          |\n\n```yaml\nalerting:\n  googlechat:\n    webhook-url: \"https://chat.googleapis.com/v1/spaces/*******/messages?key=**********&token=********\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: googlechat\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\n\n#### Configuring Gotify alerts\n| Parameter                                     | Description                                                                                 | Default               |\n|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:----------------------|\n| `alerting.gotify`                             | Configuration for alerts of type `gotify`                                                   | `{}`                  |\n| `alerting.gotify.server-url`                  | Gotify server URL                                                                           | Required `\"\"`         |\n| `alerting.gotify.token`                       | Token that is used for authentication.                                                      | Required `\"\"`         |\n| `alerting.gotify.priority`                    | Priority of the alert according to Gotify standards.                                        | `5`                   |\n| `alerting.gotify.title`                       | Title of the notification                                                                   | `\"Gatus: <endpoint>\"` |\n| `alerting.gotify.default-alert`               | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A                   |\n\n```yaml\nalerting:\n  gotify:\n    server-url: \"https://gotify.example\"\n    token: \"**************\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: gotify\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\nHere's an example of what the notifications look like:\n\n![Gotify notifications](.github/assets/gotify-alerts.png)\n\n\n#### Configuring HomeAssistant alerts\n| Parameter                                  | Description                                                                            | Default Value |\n|:-------------------------------------------|:---------------------------------------------------------------------------------------|:--------------|\n| `alerting.homeassistant.url`               | HomeAssistant instance URL                                                             | Required `\"\"` |\n| `alerting.homeassistant.token`             | Long-lived access token from HomeAssistant                                             | Required `\"\"` |\n| `alerting.homeassistant.default-alert`     | Default alert configuration to use for endpoints with an alert of the appropriate type | `{}`          |\n| `alerting.homeassistant.overrides`         | List of overrides that may be prioritized over the default configuration               | `[]`          |\n| `alerting.homeassistant.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration    | `\"\"`          |\n| `alerting.homeassistant.overrides[].*`     | See `alerting.homeassistant.*` parameters                                              | `{}`          |\n\n```yaml\nalerting:\n  homeassistant:\n    url: \"http://homeassistant:8123\"  # URL of your HomeAssistant instance\n    token: \"YOUR_LONG_LIVED_ACCESS_TOKEN\"  # Long-lived access token from HomeAssistant\n\nendpoints:\n  - name: my-service\n    url: \"https://my-service.com\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: homeassistant\n        enabled: true\n        send-on-resolved: true\n        description: \"My service health check\"\n        failure-threshold: 3\n        success-threshold: 2\n```\n\nThe alerts will be sent as events to HomeAssistant with the event type `gatus_alert`. The event data includes:\n- `status`: \"triggered\" or \"resolved\"\n- `endpoint`: The name of the monitored endpoint\n- `description`: The alert description if provided\n- `conditions`: List of conditions and their results\n- `failure_count`: Number of consecutive failures (when triggered)\n- `success_count`: Number of consecutive successes (when resolved)\n\nYou can use these events in HomeAssistant automations to:\n- Send notifications\n- Control devices\n- Trigger scenes\n- Log to history\n- And more\n\nExample HomeAssistant automation:\n```yaml\nautomation:\n  - alias: \"Gatus Alert Handler\"\n    trigger:\n      platform: event\n      event_type: gatus_alert\n    action:\n      - service: notify.notify\n        data_template:\n          title: \"Gatus Alert: {{ trigger.event.data.event_data.endpoint }}\"\n          message: >\n            Status: {{ trigger.event.data.event_data.status }}\n            {% if trigger.event.data.event_data.description %}\n            Description: {{ trigger.event.data.event_data.description }}\n            {% endif %}\n            {% for condition in trigger.event.data.event_data.conditions %}\n            {{ '✅' if condition.success else '❌' }} {{ condition.condition }}\n            {% endfor %}\n```\n\nTo get your HomeAssistant long-lived access token:\n1. Open HomeAssistant\n2. Click on your profile name (bottom left)\n3. Scroll down to \"Long-Lived Access Tokens\"\n4. Click \"Create Token\"\n5. Give it a name (e.g., \"Gatus\")\n6. Copy the token - you'll only see it once!\n\n\n#### Configuring IFTTT alerts\n\n> ⚠️ **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.\n\n| Parameter                          | Description                                                                                | Default       |\n|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.ifttt`                   | Configuration for alerts of type `ifttt`                                                   | `{}`          |\n| `alerting.ifttt.webhook-key`       | IFTTT Webhook key                                                                          | Required `\"\"` |\n| `alerting.ifttt.event-name`        | IFTTT event name                                                                           | Required `\"\"` |\n| `alerting.ifttt.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.ifttt.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.ifttt.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.ifttt.overrides[].*`     | See `alerting.ifttt.*` parameters                                                          | `{}`          |\n\n```yaml\nalerting:\n  ifttt:\n    webhook-key: \"YOUR_WEBHOOK_KEY\"\n    event-name: \"gatus_alert\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: ifttt\n        send-on-resolved: true\n```\n\n\n#### Configuring Ilert alerts\n| Parameter                          | Description                                                                                | Default |\n|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------|\n| `alerting.ilert`                   | Configuration for alerts of type `ilert`                                                   | `{}`    |\n| `alerting.ilert.integration-key`   | ilert Alert Source integration key                                                         | `\"\"`    |\n| `alerting.ilert.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A     |\n| `alerting.ilert.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`    |\n| `alerting.ilert.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`    |\n| `alerting.ilert.overrides[].*`     | See `alerting.ilert.*` parameters                                                          | `{}`    |\n\nIt is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts\nof type `ilert`, because unlike other alerts, the operation resulting from setting said\nparameter to `true` will not create another alert but mark the alert as resolved on\nilert instead.\n\nBehavior:\n- By default, `alerting.ilert.integration-key` is used as the integration key\n- 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\n\n```yaml\nalerting:\n  ilert:\n    integration-key: \"********************************\"\n    # You can also add group-specific integration keys, which will\n    # override the integration key above for the specified groups\n    overrides:\n      - group: \"core\"\n        integration-key: \"********************************\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 30s\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: ilert\n        failure-threshold: 3\n        success-threshold: 5\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n```\n\n\n#### Configuring Incident.io alerts\n| Parameter                                | Description                                                                                | Default       |\n|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.incident-io`                   | Configuration for alerts of type `incident-io`                                             | `{}`          |\n| `alerting.incident-io.url`               | url to trigger an alert event.                                                             | Required `\"\"` |\n| `alerting.incident-io.auth-token`        | Token that is used for authentication.                                                     | Required `\"\"` |\n| `alerting.incident-io.source-url`        | Source URL                                                                                 | `\"\"`          |\n| `alerting.incident-io.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.incident-io.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.incident-io.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.incident-io.overrides[].*`     | See `alerting.incident-io.*` parameters                                                    | `{}`          |\n\n```yaml\nalerting:\n  incident-io:\n    url: \"*****************\"\n    auth-token: \"********************************************\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 30s\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: incident-io\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\nIn order to get the required alert source config id and authentication token, you must configure an HTTP alert source.\n\n> **_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`\n\n\n#### Configuring Line alerts\n\n| Parameter                            | Description                                                                                | Default       |\n|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.line`                      | Configuration for alerts of type `line`                                                    | `{}`          |\n| `alerting.line.channel-access-token` | Line Messaging API channel access token                                                    | Required `\"\"` |\n| `alerting.line.user-ids`             | List of Line user IDs to send messages to (this can be user ids, room ids or group ids)    | Required `[]` |\n| `alerting.line.default-alert`        | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.line.overrides`            | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.line.overrides[].group`    | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.line.overrides[].*`        | See `alerting.line.*` parameters                                                           | `{}`          |\n\n```yaml\nalerting:\n  line:\n    channel-access-token: \"YOUR_CHANNEL_ACCESS_TOKEN\"\n    user-ids:\n      - \"U1234567890abcdef\" # This can be a group id, room id or user id\n      - \"U2345678901bcdefg\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: line\n        send-on-resolved: true\n```\n\n\n#### Configuring Matrix alerts\n| Parameter                                | Description                                                                                | Default                            |\n|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------|\n| `alerting.matrix`                        | Configuration for alerts of type `matrix`                                                  | `{}`                               |\n| `alerting.matrix.server-url`             | Homeserver URL                                                                             | `https://matrix-client.matrix.org` |\n| `alerting.matrix.access-token`           | Bot user access token (see https://webapps.stackexchange.com/q/131056)                     | Required `\"\"`                      |\n| `alerting.matrix.internal-room-id`       | Internal room ID of room to send alerts to (can be found in Room Settings > Advanced)      | Required `\"\"`                      |\n| `alerting.matrix.default-alert`          | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A                                |\n| `alerting.matrix.overrides`              | List of overrides that may be prioritized over the default configuration                   | `[]`                               |\n| `alerting.matrix.overrides[].group`      | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`                               |\n| `alerting.matrix.overrides[].*`          | See `alerting.matrix.*` parameters                                                         | `{}`                               |\n\n```yaml\nalerting:\n  matrix:\n    server-url: \"https://matrix-client.matrix.org\"\n    access-token: \"123456\"\n    internal-room-id: \"!example:matrix.org\"\n\nendpoints:\n  - name: website\n    interval: 5m\n    url: \"https://twin.sh/health\"\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: matrix\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n```\n\n\n#### Configuring Mattermost alerts\n| Parameter                                     | Description                                                                                 | Default       |\n|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|\n| `alerting.mattermost`                         | Configuration for alerts of type `mattermost`                                               | `{}`          |\n| `alerting.mattermost.webhook-url`             | Mattermost Webhook URL                                                                      | Required `\"\"` |\n| `alerting.mattermost.channel`                 | Mattermost channel name override (optional)                                                 | `\"\"`          |\n| `alerting.mattermost.client`                  | Client configuration. <br />See [Client configuration](#client-configuration).              | `{}`          |\n| `alerting.mattermost.default-alert`           | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A           |\n| `alerting.mattermost.overrides`               | List of overrides that may be prioritized over the default configuration                    | `[]`          |\n| `alerting.mattermost.overrides[].group`       | Endpoint group for which the configuration will be overridden by this configuration         | `\"\"`          |\n| `alerting.mattermost.overrides[].*`           | See `alerting.mattermost.*` parameters                                                      | `{}`          |\n\n```yaml\nalerting:\n  mattermost:\n    webhook-url: \"http://**********/hooks/**********\"\n    client:\n      insecure: true\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: mattermost\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\nHere's an example of what the notifications look like:\n\n![Mattermost notifications](.github/assets/mattermost-alerts.png)\n\n\n#### Configuring Messagebird alerts\n| Parameter                            | Description                                                                                | Default       |\n|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.messagebird`               | Configuration for alerts of type `messagebird`                                             | `{}`          |\n| `alerting.messagebird.access-key`    | Messagebird access key                                                                     | Required `\"\"` |\n| `alerting.messagebird.originator`    | The sender of the message                                                                  | Required `\"\"` |\n| `alerting.messagebird.recipients`    | The recipients of the message                                                              | Required `\"\"` |\n| `alerting.messagebird.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n\nExample of sending **SMS** text message alert using Messagebird:\n```yaml\nalerting:\n  messagebird:\n    access-key: \"...\"\n    originator: \"31619191918\"\n    recipients: \"31619191919,31619191920\"\n\nendpoints:\n  - name: website\n    interval: 5m\n    url: \"https://twin.sh/health\"\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: messagebird\n        failure-threshold: 3\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n```\n\n\n#### Configuring New Relic alerts\n\n> ⚠️ **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.\n\n| Parameter                             | Description                                                                                | Default       |\n|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.newrelic`                   | Configuration for alerts of type `newrelic`                                                | `{}`          |\n| `alerting.newrelic.api-key`           | New Relic API key                                                                          | Required `\"\"` |\n| `alerting.newrelic.account-id`        | New Relic account ID                                                                       | Required `\"\"` |\n| `alerting.newrelic.region`            | Region (US or EU)                                                                          | `\"US\"`        |\n| `alerting.newrelic.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.newrelic.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.newrelic.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.newrelic.overrides[].*`     | See `alerting.newrelic.*` parameters                                                       | `{}`          |\n\n```yaml\nalerting:\n  newrelic:\n    api-key: \"YOUR_API_KEY\"\n    account-id: \"1234567\"\n    region: \"US\"  # or \"EU\" for European region\n\nendpoints:\n  - name: example\n    url: \"https://example.org\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: newrelic\n        send-on-resolved: true\n```\n\n\n#### Configuring n8n alerts\n| Parameter                        | Description                                                                                | Default       |\n|:---------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.n8n`                   | Configuration for alerts of type `n8n`                                                     | `{}`          |\n| `alerting.n8n.webhook-url`       | n8n webhook URL                                                                            | Required `\"\"` |\n| `alerting.n8n.title`             | Title of the alert sent to n8n                                                             | `\"\"`          |\n| `alerting.n8n.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.n8n.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.n8n.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.n8n.overrides[].*`     | See `alerting.n8n.*` parameters                                                            | `{}`          |\n\n[n8n](https://n8n.io/) is a workflow automation platform that allows you to automate tasks across different applications and services using webhooks.\n\nSee [n8n-nodes-gatus-trigger](https://github.com/TwiN/n8n-nodes-gatus-trigger) for a n8n community node that can be used as trigger.\n\nExample:\n```yaml\nalerting:\n  n8n:\n    webhook-url: \"https://your-n8n-instance.com/webhook/your-webhook-id\"\n    title: \"Gatus Monitoring\"\n    default-alert:\n      send-on-resolved: true\n\nendpoints:\n  - name: example\n    url: \"https://example.org\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: n8n\n        description: \"Health check alert\"\n```\n\nThe JSON payload sent to the n8n webhook will include:\n- `title`: The configured title\n- `endpoint_name`: Name of the endpoint\n- `endpoint_group`: Group of the endpoint (if any)\n- `endpoint_url`: URL being monitored\n- `alert_description`: Custom alert description\n- `resolved`: Boolean indicating if the alert is resolved\n- `message`: Human-readable alert message\n- `condition_results`: Array of condition results with their success status\n\n\n#### Configuring Ntfy alerts\n| Parameter                            | Description                                                                                                                                  | Default           |\n|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|\n| `alerting.ntfy`                      | Configuration for alerts of type `ntfy`                                                                                                      | `{}`              |\n| `alerting.ntfy.topic`                | Topic at which the alert will be sent                                                                                                        | Required `\"\"`     |\n| `alerting.ntfy.url`                  | The URL of the target server                                                                                                                 | `https://ntfy.sh` |\n| `alerting.ntfy.token`                | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics                                                            | `\"\"`              |\n| `alerting.ntfy.email`                | E-mail address for additional e-mail notifications                                                                                           | `\"\"`              |\n| `alerting.ntfy.click`                | Website opened when notification is clicked                                                                                                  | `\"\"`              |\n| `alerting.ntfy.priority`             | The priority of the alert                                                                                                                    | `3`               |\n| `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`           |\n| `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`           |\n| `alerting.ntfy.default-alert`        | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert)                                                   | N/A               |\n| `alerting.ntfy.overrides`            | List of overrides that may be prioritized over the default configuration                                                                     | `[]`              |\n| `alerting.ntfy.overrides[].group`    | Endpoint group for which the configuration will be overridden by this configuration                                                          | `\"\"`              |\n| `alerting.ntfy.overrides[].*`        | See `alerting.ntfy.*` parameters                                                                                                             | `{}`              |\n\n[ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop\nand mobile notifications, making it an awesome addition to Gatus.\n\nExample:\n```yaml\nalerting:\n  ntfy:\n    topic: \"gatus-test-topic\"\n    priority: 2\n    token: faketoken\n    default-alert:\n      failure-threshold: 3\n      send-on-resolved: true\n    # You can also add group-specific to keys, which will\n    # override the to key above for the specified groups\n    overrides:\n      - group: \"other\"\n        topic: \"gatus-other-test-topic\"\n        priority: 4\n        click: \"https://example.com\"\n\nendpoints:\n  - name: website\n    interval: 5m\n    url: \"https://twin.sh/health\"\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: ntfy\n  - name: other example\n    group: other\n    interval: 30m\n    url: \"https://example.com\"\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n    alerts:\n      - type: ntfy\n        description: example\n```\n\n\n#### Configuring Opsgenie alerts\n| Parameter                         | Description                                                                                | Default              |\n|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------|\n| `alerting.opsgenie`               | Configuration for alerts of type `opsgenie`                                                | `{}`                 |\n| `alerting.opsgenie.api-key`       | Opsgenie API Key                                                                           | Required `\"\"`        |\n| `alerting.opsgenie.priority`      | Priority level of the alert.                                                               | `P1`                 |\n| `alerting.opsgenie.source`        | Source field of the alert.                                                                 | `gatus`              |\n| `alerting.opsgenie.entity-prefix` | Entity field prefix.                                                                       | `gatus-`             |\n| `alerting.opsgenie.alias-prefix`  | Alias field prefix.                                                                        | `gatus-healthcheck-` |\n| `alerting.opsgenie.tags`          | Tags of alert.                                                                             | `[]`                 |\n| `alerting.opsgenie.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A                  |\n\nOpsgenie provider will automatically open and close alerts.\n\n```yaml\nalerting:\n  opsgenie:\n    api-key: \"00000000-0000-0000-0000-000000000000\"\n```\n\n\n#### Configuring PagerDuty alerts\n| Parameter                              | Description                                                                                | Default |\n|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------|\n| `alerting.pagerduty`                   | Configuration for alerts of type `pagerduty`                                               | `{}`    |\n| `alerting.pagerduty.integration-key`   | PagerDuty Events API v2 integration key                                                    | `\"\"`    |\n| `alerting.pagerduty.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A     |\n| `alerting.pagerduty.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`    |\n| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`    |\n| `alerting.pagerduty.overrides[].*`     | See `alerting.pagerduty.*` parameters                                                      | `{}`    |\n\nIt is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts\nof type `pagerduty`, because unlike other alerts, the operation resulting from setting said\nparameter to `true` will not create another incident but mark the incident as resolved on\nPagerDuty instead.\n\nBehavior:\n- By default, `alerting.pagerduty.integration-key` is used as the integration key\n- 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\n\n```yaml\nalerting:\n  pagerduty:\n    integration-key: \"********************************\"\n    # You can also add group-specific integration keys, which will\n    # override the integration key above for the specified groups\n    overrides:\n      - group: \"core\"\n        integration-key: \"********************************\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 30s\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: pagerduty\n        failure-threshold: 3\n        success-threshold: 5\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n\n  - name: back-end\n    group: core\n    url: \"https://example.org/\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[CERTIFICATE_EXPIRATION] > 48h\"\n    alerts:\n      - type: pagerduty\n        failure-threshold: 3\n        success-threshold: 5\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n```\n\n\n#### Configuring Plivo alerts\n\n> ⚠️ **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.\n\n| Parameter                          | Description                                                                                | Default       |\n|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.plivo`                   | Configuration for alerts of type `plivo`                                                   | `{}`          |\n| `alerting.plivo.auth-id`           | Plivo Auth ID                                                                              | Required `\"\"` |\n| `alerting.plivo.auth-token`        | Plivo Auth Token                                                                           | Required `\"\"` |\n| `alerting.plivo.from`              | Phone number to send SMS from                                                              | Required `\"\"` |\n| `alerting.plivo.to`                | List of phone numbers to send SMS to                                                       | Required `[]` |\n| `alerting.plivo.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.plivo.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.plivo.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.plivo.overrides[].*`     | See `alerting.plivo.*` parameters                                                          | `{}`          |\n\n```yaml\nalerting:\n  plivo:\n    auth-id: \"MAXXXXXXXXXXXXXXXXXX\"\n    auth-token: \"your-auth-token\"\n    from: \"+1234567890\"\n    to:\n      - \"+0987654321\"\n      - \"+1122334455\"\n\nendpoints:\n  - name: website\n    interval: 30s\n    url: \"https://twin.sh/health\"\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: plivo\n        failure-threshold: 5\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n```\n\n\n#### Configuring Pushover alerts\n| Parameter                             | Description                                                                                                  | Default               |\n|:--------------------------------------|:-------------------------------------------------------------------------------------------------------------|:----------------------|\n| `alerting.pushover`                   | Configuration for alerts of type `pushover`                                                                  | `{}`                  |\n| `alerting.pushover.application-token` | Pushover application token                                                                                   | `\"\"`                  |\n| `alerting.pushover.user-key`          | User or group key                                                                                            | `\"\"`                  |\n| `alerting.pushover.title`             | Fixed title for all messages sent via Pushover                                                               | `\"Gatus: <endpoint>\"` |\n| `alerting.pushover.priority`          | Priority of all messages, ranging from -2 (very low) to 2 (emergency)                                        | `0`                   |\n| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency)                   | `0`                   |\n| `alerting.pushover.sound`             | Sound of all messages<br />See [sounds](https://pushover.net/api#sounds) for all valid choices.              | `\"\"`                  |\n| `alerting.pushover.ttl`               | Set the Time-to-live of the message to be automatically deleted from pushover notifications                  | `0`                   |\n| `alerting.pushover.device`            | Device to send the message to (optional)<br/>See [devices](https://pushover.net/api#identifiers) for details | `\"\"` (all devices)    |\n| `alerting.pushover.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert)                   | N/A                   |\n\n```yaml\nalerting:\n  pushover:\n    application-token: \"******************************\"\n    user-key: \"******************************\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 30s\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: pushover\n        failure-threshold: 3\n        success-threshold: 5\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n```\n\n\n#### Configuring Rocket.Chat alerts\n\n> ⚠️ **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.\n\n| Parameter                               | Description                                                                                | Default       |\n|:----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.rocketchat`                   | Configuration for alerts of type `rocketchat`                                              | `{}`          |\n| `alerting.rocketchat.webhook-url`       | Rocket.Chat incoming webhook URL                                                           | Required `\"\"` |\n| `alerting.rocketchat.channel`           | Optional channel override                                                                  | `\"\"`          |\n| `alerting.rocketchat.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.rocketchat.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.rocketchat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.rocketchat.overrides[].*`     | See `alerting.rocketchat.*` parameters                                                     | `{}`          |\n\n```yaml\nalerting:\n  rocketchat:\n    webhook-url: \"https://your-rocketchat.com/hooks/YOUR_WEBHOOK_ID/YOUR_TOKEN\"\n    channel: \"#alerts\"  # Optional\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: rocketchat\n        send-on-resolved: true\n```\n\n\n#### Configuring SendGrid alerts\n\n> ⚠️ **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.\n\n| Parameter                             | Description                                                                                | Default       |\n|:--------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.sendgrid`                   | Configuration for alerts of type `sendgrid`                                                | `{}`          |\n| `alerting.sendgrid.api-key`           | SendGrid API key                                                                           | Required `\"\"` |\n| `alerting.sendgrid.from`              | Email address to send from                                                                 | Required `\"\"` |\n| `alerting.sendgrid.to`                | Email address(es) to send alerts to (comma-separated for multiple recipients)              | Required `\"\"` |\n| `alerting.sendgrid.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.sendgrid.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.sendgrid.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.sendgrid.overrides[].*`     | See `alerting.sendgrid.*` parameters                                                       | `{}`          |\n\n```yaml\nalerting:\n  sendgrid:\n    api-key: \"SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"\n    from: \"alerts@example.com\"\n    to: \"admin@example.com,ops@example.com\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: sendgrid\n        send-on-resolved: true\n```\n\n\n#### Configuring Signal alerts\n\n> ⚠️ **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.\n\n| Parameter                           | Description                                                                                | Default       |\n|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.signal`                   | Configuration for alerts of type `signal`                                                  | `{}`          |\n| `alerting.signal.api-url`           | Signal API URL (e.g., signal-cli-rest-api instance)                                        | Required `\"\"` |\n| `alerting.signal.number`            | Sender phone number                                                                        | Required `\"\"` |\n| `alerting.signal.recipients`        | List of recipient phone numbers                                                            | Required `[]` |\n| `alerting.signal.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.signal.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.signal.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.signal.overrides[].*`     | See `alerting.signal.*` parameters                                                         | `{}`          |\n\n```yaml\nalerting:\n  signal:\n    api-url: \"http://localhost:8080\"\n    number: \"+1234567890\"\n    recipients:\n      - \"+0987654321\"\n      - \"+1122334455\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: signal\n        send-on-resolved: true\n```\n\n\n#### Configuring SIGNL4 alerts\n\nSIGNL4 is a mobile alerting and incident management service that sends critical alerts to team members via mobile push, SMS, voice calls, and email.\n\n| Parameter                           | Description                                                                                | Default       |\n|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.signl4`                   | Configuration for alerts of type `signl4`                                                  | `{}`          |\n| `alerting.signl4.team-secret`       | SIGNL4 team secret (part of webhook URL)                                                   | Required `\"\"` |\n| `alerting.signl4.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.signl4.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.signl4.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.signl4.overrides[].*`     | See `alerting.signl4.*` parameters                                                         | `{}`          |\n\n```yaml\nalerting:\n  signl4:\n    team-secret: \"your-team-secret-here\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: signl4\n        send-on-resolved: true\n```\n\n\n#### Configuring Slack alerts\n| Parameter                          | Description                                                                                | Default                             |\n|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|\n| `alerting.slack`                   | Configuration for alerts of type `slack`                                                   | `{}`                                |\n| `alerting.slack.webhook-url`       | Slack Webhook URL                                                                          | Required `\"\"`                       |\n| `alerting.slack.title`             | Title of the notification                                                                  | `\":helmet_with_white_cross: Gatus\"` |\n| `alerting.slack.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A                                 |\n| `alerting.slack.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`                                |\n| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`                                |\n| `alerting.slack.overrides[].*`     | See `alerting.slack.*` parameters                                                          | `{}`                                |\n\n```yaml\nalerting:\n  slack:\n    webhook-url: \"https://hooks.slack.com/services/**********/**********/**********\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 30s\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: slack\n        description: \"healthcheck failed 3 times in a row\"\n        send-on-resolved: true\n      - type: slack\n        failure-threshold: 5\n        description: \"healthcheck failed 5 times in a row\"\n        send-on-resolved: true\n```\n\nHere's an example of what the notifications look like:\n\n![Slack notifications](.github/assets/slack-alerts.png)\n\n\n#### Configuring Splunk alerts\n\n| Parameter                           | Description                                                                                | Default         |\n|:------------------------------------|:-------------------------------------------------------------------------------------------|:----------------|\n| `alerting.splunk`                   | Configuration for alerts of type `splunk`                                                  | `{}`            |\n| `alerting.splunk.hec-url`           | Splunk HEC (HTTP Event Collector) URL                                                      | Required `\"\"`   |\n| `alerting.splunk.hec-token`         | Splunk HEC token                                                                           | Required `\"\"`   |\n| `alerting.splunk.source`            | Event source                                                                               | `\"gatus\"`       |\n| `alerting.splunk.sourcetype`        | Event source type                                                                          | `\"gatus:alert\"` |\n| `alerting.splunk.index`             | Splunk index                                                                               | `\"\"`            |\n| `alerting.splunk.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A             |\n| `alerting.splunk.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`            |\n| `alerting.splunk.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`            |\n| `alerting.splunk.overrides[].*`     | See `alerting.splunk.*` parameters                                                         | `{}`            |\n\n```yaml\nalerting:\n  splunk:\n    hec-url: \"https://splunk.example.com:8088\"\n    hec-token: \"YOUR_HEC_TOKEN\"\n    index: \"main\"  # Optional\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: splunk\n        send-on-resolved: true\n```\n\n\n#### Configuring Squadcast alerts\n\n> ⚠️ **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.\n\n| Parameter                              | Description                                                                                | Default       |\n|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.squadcast`                   | Configuration for alerts of type `squadcast`                                               | `{}`          |\n| `alerting.squadcast.webhook-url`       | Squadcast webhook URL                                                                      | Required `\"\"` |\n| `alerting.squadcast.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.squadcast.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.squadcast.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.squadcast.overrides[].*`     | See `alerting.squadcast.*` parameters                                                      | `{}`          |\n\n```yaml\nalerting:\n  squadcast:\n    webhook-url: \"https://api.squadcast.com/v3/incidents/api/YOUR_API_KEY\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: squadcast\n        send-on-resolved: true\n```\n\n\n#### Configuring Teams alerts *(Deprecated)*\n\n> [!CAUTION]\n> **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/)).\n> 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.\n\n| Parameter                          | Description                                                                                | Default             |\n|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------------|\n| `alerting.teams`                   | Configuration for alerts of type `teams`                                                   | `{}`                |\n| `alerting.teams.webhook-url`       | Teams Webhook URL                                                                          | Required `\"\"`       |\n| `alerting.teams.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A                 |\n| `alerting.teams.title`             | Title of the notification                                                                  | `\"&#x1F6A8; Gatus\"` |\n| `alerting.teams.client.insecure`   | Whether to skip TLS verification                                                           | `false`             |\n| `alerting.teams.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`                |\n| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`                |\n| `alerting.teams.overrides[].*`     | See `alerting.teams.*` parameters                                                          | `{}`                |\n\n```yaml\nalerting:\n  teams:\n    webhook-url: \"https://********.webhook.office.com/webhookb2/************\"\n    client:\n      insecure: false\n    # You can also add group-specific to keys, which will\n    # override the to key above for the specified groups\n    overrides:\n      - group: \"core\"\n        webhook-url: \"https://********.webhook.office.com/webhookb3/************\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 30s\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: teams\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n\n  - name: back-end\n    group: core\n    url: \"https://example.org/\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[CERTIFICATE_EXPIRATION] > 48h\"\n    alerts:\n      - type: teams\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\nHere's an example of what the notifications look like:\n\n![Teams notifications](.github/assets/teams-alerts.png)\n\n\n#### Configuring Teams Workflow alerts\n\n> [!NOTE]\n> 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).\n\n| Parameter                                    | Description                                                                                | Default            |\n|:---------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------|\n| `alerting.teams-workflows`                   | Configuration for alerts of type `teams`                                                   | `{}`               |\n| `alerting.teams-workflows.webhook-url`       | Teams Webhook URL                                                                          | Required `\"\"`      |\n| `alerting.teams-workflows.title`             | Title of the notification                                                                  | `\"&#x26D1; Gatus\"` |\n| `alerting.teams-workflows.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A                |\n| `alerting.teams-workflows.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`               |\n| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`               |\n| `alerting.teams-workflows.overrides[].*`     | See `alerting.teams-workflows.*` parameters                                                | `{}`               |\n\n```yaml\nalerting:\n  teams-workflows:\n    webhook-url: \"https://********.webhook.office.com/webhookb2/************\"\n    # You can also add group-specific to keys, which will\n    # override the to key above for the specified groups\n    overrides:\n      - group: \"core\"\n        webhook-url: \"https://********.webhook.office.com/webhookb3/************\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 30s\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: teams-workflows\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n\n  - name: back-end\n    group: core\n    url: \"https://example.org/\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[CERTIFICATE_EXPIRATION] > 48h\"\n    alerts:\n      - type: teams-workflows\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\nHere's an example of what the notifications look like:\n\n![Teams Workflow notifications](.github/assets/teams-workflows-alerts.png)\n\n\n#### Configuring Telegram alerts\n| Parameter                             | Description                                                                                | Default                    |\n|:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|\n| `alerting.telegram`                   | Configuration for alerts of type `telegram`                                                | `{}`                       |\n| `alerting.telegram.token`             | Telegram Bot Token                                                                         | Required `\"\"`              |\n| `alerting.telegram.id`                | Telegram Chat ID                                                                           | Required `\"\"`              |\n| `alerting.telegram.topic-id`          | Telegram Topic ID in a group corresponds to `message_thread_id` in the Telegram API        | `\"\"`                       |\n| `alerting.telegram.api-url`           | Telegram API URL                                                                           | `https://api.telegram.org` |\n| `alerting.telegram.client`            | Client configuration. <br />See [Client configuration](#client-configuration).             | `{}`                       |\n| `alerting.telegram.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A                        |\n| `alerting.telegram.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`                       |\n| `alerting.telegram.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`                       |\n| `alerting.telegram.overrides[].*`     | See `alerting.telegram.*` parameters                                                       | `{}`                       |\n\n```yaml\nalerting:\n  telegram:\n    token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\"\n    id: \"0123456789\"\n    topic-id: \"7\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 30s\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n    alerts:\n      - type: telegram\n        send-on-resolved: true\n```\n\nHere's an example of what the notifications look like:\n\n![Telegram notifications](.github/assets/telegram-alerts.png)\n\n\n#### Configuring Twilio alerts\n| Parameter                       | Description                                                                                | Default       |\n|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.twilio`               | Settings for alerts of type `twilio`                                                       | `{}`          |\n| `alerting.twilio.sid`           | Twilio account SID                                                                         | Required `\"\"` |\n| `alerting.twilio.token`         | Twilio auth token                                                                          | Required `\"\"` |\n| `alerting.twilio.from`          | Number to send Twilio alerts from                                                          | Required `\"\"` |\n| `alerting.twilio.to`            | Number to send twilio alerts to                                                            | Required `\"\"` |\n| `alerting.twilio.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n\nCustom message templates are supported via the following additional parameters:\n\n| Parameter                               | Description                                                                                | Default |\n|:----------------------------------------|:-------------------------------------------------------------------------------------------|:--------|\n| `alerting.twilio.text-twilio-triggered` | Custom message template for triggered alerts. Supports `[ENDPOINT]`, `[ALERT_DESCRIPTION]` | `\"\"`    |\n| `alerting.twilio.text-twilio-resolved`  | Custom message template for resolved alerts. Supports `[ENDPOINT]`, `[ALERT_DESCRIPTION]`  | `\"\"`    |\n\n```yaml\nalerting:\n  twilio:\n    sid: \"...\"\n    token: \"...\"\n    from: \"+1-234-567-8901\"\n    to: \"+1-234-567-8901\"\n    # Custom message templates using placeholders (optional)\n    # Supports both old format {endpoint}/{description} and new format [ENDPOINT]/[ALERT_DESCRIPTION]\n    text-twilio-triggered: \"🚨 ALERT: [ENDPOINT] is down! [ALERT_DESCRIPTION]\"\n    text-twilio-resolved: \"✅ RESOLVED: [ENDPOINT] is back up! [ALERT_DESCRIPTION]\"\n\nendpoints:\n  - name: website\n    interval: 30s\n    url: \"https://twin.sh/health\"\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: twilio\n        failure-threshold: 5\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n```\n\n\n#### Configuring Vonage alerts\n\n> ⚠️ **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.\n\n| Parameter                           | Description                                                                                | Default       |\n|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.vonage`                   | Configuration for alerts of type `vonage`                                                  | `{}`          |\n| `alerting.vonage.api-key`           | Vonage API key                                                                             | Required `\"\"` |\n| `alerting.vonage.api-secret`        | Vonage API secret                                                                          | Required `\"\"` |\n| `alerting.vonage.from`              | Sender name or phone number                                                                | Required `\"\"` |\n| `alerting.vonage.to`                | Recipient phone number                                                                     | Required `\"\"` |\n| `alerting.vonage.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.vonage.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.vonage.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.vonage.overrides[].*`     | See `alerting.vonage.*` parameters                                                         | `{}`          |\n\n```yaml\nalerting:\n  vonage:\n    api-key: \"YOUR_API_KEY\"\n    api-secret: \"YOUR_API_SECRET\"\n    from: \"Gatus\"\n    to: \"+1234567890\"\n```\n\nExample of sending alerts to Vonage:\n```yaml\nendpoints:\n  - name: website\n    url: \"https://example.org\"\n    alerts:\n      - type: vonage\n        failure-threshold: 5\n        send-on-resolved: true\n        description: \"healthcheck failed\"\n```\n\n\n#### Configuring Webex alerts\n\n> ⚠️ **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.\n\n| Parameter                          | Description                                                                                | Default       |\n|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.webex`                   | Configuration for alerts of type `webex`                                                   | `{}`          |\n| `alerting.webex.webhook-url`       | Webex Teams webhook URL                                                                    | Required `\"\"` |\n| `alerting.webex.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.webex.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.webex.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.webex.overrides[].*`     | See `alerting.webex.*` parameters                                                          | `{}`          |\n\n```yaml\nalerting:\n  webex:\n    webhook-url: \"https://webexapis.com/v1/webhooks/incoming/YOUR_WEBHOOK_ID\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: webex\n        send-on-resolved: true\n```\n\n\n#### Configuring Zapier alerts\n\n> ⚠️ **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.\n\n| Parameter                       | Description                                                                                | Default       |\n|:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.zapier`               | Configuration for alerts of type `zapier`                                                  | `{}`          |\n| `alerting.zapier.webhook-url`   | Zapier webhook URL                                                                         | Required `\"\"` |\n| `alerting.zapier.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.zapier.overrides`     | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.zapier.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration    | `\"\"`          |\n| `alerting.zapier.overrides[].*` | See `alerting.zapier.*` parameters                                                        | `{}`          |\n\n```yaml\nalerting:\n  zapier:\n    webhook-url: \"https://hooks.zapier.com/hooks/catch/YOUR_WEBHOOK_ID/\"\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: zapier\n        send-on-resolved: true\n```\n\n\n#### Configuring Zulip alerts\n| Parameter                          | Description                                                                         | Default       |\n|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|\n| `alerting.zulip`                   | Configuration for alerts of type `zulip`                                            | `{}`          |\n| `alerting.zulip.bot-email`         | Bot Email                                                                           | Required `\"\"` |\n| `alerting.zulip.bot-api-key`       | Bot API key                                                                         | Required `\"\"` |\n| `alerting.zulip.domain`            | Full organization domain (e.g.: yourZulipDomain.zulipchat.com)                      | Required `\"\"` |\n| `alerting.zulip.channel-id`        | The channel ID where Gatus will send the alerts                                     | Required `\"\"` |\n| `alerting.zulip.overrides`         | List of overrides that may be prioritized over the default configuration            | `[]`          |\n| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `\"\"`          |\n| `alerting.zulip.overrides[].*`     | See `alerting.zulip.*` parameters                                                   | `{}`          |\n\n```yaml\nalerting:\n  zulip:\n    bot-email: gatus-bot@some.zulip.org\n    bot-api-key: \"********************************\"\n    domain: some.zulip.org\n    channel-id: 123456\n\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: zulip\n        description: \"healthcheck failed\"\n        send-on-resolved: true\n```\n\n\n#### Configuring custom alerts\n| Parameter                           | Description                                                                                | Default       |\n|:------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|\n| `alerting.custom`                   | Configuration for custom actions on failure or alerts                                      | `{}`          |\n| `alerting.custom.url`               | Custom alerting request url                                                                | Required `\"\"` |\n| `alerting.custom.method`            | Request method                                                                             | `GET`         |\n| `alerting.custom.body`              | Custom alerting request body.                                                              | `\"\"`          |\n| `alerting.custom.headers`           | Custom alerting request headers                                                            | `{}`          |\n| `alerting.custom.client`            | Client configuration. <br />See [Client configuration](#client-configuration).             | `{}`          |\n| `alerting.custom.default-alert`     | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |\n| `alerting.custom.overrides`         | List of overrides that may be prioritized over the default configuration                   | `[]`          |\n| `alerting.custom.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration        | `\"\"`          |\n| `alerting.custom.overrides[].*`     | See `alerting.custom.*` parameters                                                         | `{}`          |\n\nWhile they're called alerts, you can use this feature to call anything.\n\nFor instance, you could automate rollbacks by having an application that keeps tracks of new deployments, and by\nleveraging Gatus, you could have Gatus call that application endpoint when an endpoint starts failing. Your application\nwould then check if the endpoint that started failing was part of the recently deployed application, and if it was,\nthen automatically roll it back.\n\nFurthermore, you may use the following placeholders in the body (`alerting.custom.body`) and in the url (`alerting.custom.url`):\n- `[ALERT_DESCRIPTION]` (resolved from `endpoints[].alerts[].description`)\n- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)\n- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)\n- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)\n- `[RESULT_ERRORS]` (resolved from the health evaluation of a given health check)\n- `[RESULT_CONDITIONS]` (condition results from the health evaluation of a given health check)\n-\nIf you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the\n`[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications.\nThe aforementioned placeholder will be replaced by `TRIGGERED` or `RESOLVED` accordingly, though it can be modified\n(details at the end of this section).\n\nFor all intents and purposes, we'll configure the custom alert with a Slack webhook, but you can call anything you want.\n```yaml\nalerting:\n  custom:\n    url: \"https://hooks.slack.com/services/**********/**********/**********\"\n    method: \"POST\"\n    body: |\n      {\n        \"text\": \"[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]\"\n      }\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    interval: 30s\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n    alerts:\n      - type: custom\n        failure-threshold: 10\n        success-threshold: 3\n        send-on-resolved: true\n        description: \"health check failed\"\n```\n\nNote that you can customize the resolved values for the `[ALERT_TRIGGERED_OR_RESOLVED]` placeholder like so:\n```yaml\nalerting:\n  custom:\n    placeholders:\n      ALERT_TRIGGERED_OR_RESOLVED:\n        TRIGGERED: \"partial_outage\"\n        RESOLVED: \"operational\"\n```\nAs a result, the `[ALERT_TRIGGERED_OR_RESOLVED]` in the body of first example of this section would be replaced by\n`partial_outage` when an alert is triggered and `operational` when an alert is resolved.\n\n\n#### Setting a default alert\n| Parameter                                    | Description                                                                   | Default |\n|:---------------------------------------------|:------------------------------------------------------------------------------|:--------|\n| `alerting.*.default-alert.enabled`           | Whether to enable the alert                                                   | N/A     |\n| `alerting.*.default-alert.failure-threshold` | Number of failures in a row needed before triggering the alert                | N/A     |\n| `alerting.*.default-alert.success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved | N/A     |\n| `alerting.*.default-alert.send-on-resolved`  | Whether to send a notification once a triggered alert is marked as resolved   | N/A     |\n| `alerting.*.default-alert.description`       | Description of the alert. Will be included in the alert sent                  | N/A     |\n\n> ⚠ You must still specify the `type` of the alert in the endpoint configuration even if you set the default alert of a provider.\n\nWhile you can specify the alert configuration directly in the endpoint definition, it's tedious and may lead to a very\nlong configuration file.\n\nTo avoid such problem, you can use the `default-alert` parameter present in each provider configuration:\n```yaml\nalerting:\n  slack:\n    webhook-url: \"https://hooks.slack.com/services/**********/**********/**********\"\n    default-alert:\n      description: \"health check failed\"\n      send-on-resolved: true\n      failure-threshold: 5\n      success-threshold: 5\n```\n\nAs a result, your Gatus configuration looks a lot tidier:\n```yaml\nendpoints:\n  - name: example\n    url: \"https://example.org\"\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: slack\n\n  - name: other-example\n    url: \"https://example.com\"\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: slack\n```\n\nIt also allows you to do things like this:\n```yaml\nendpoints:\n  - name: example\n    url: \"https://example.org\"\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: slack\n        failure-threshold: 5\n      - type: slack\n        failure-threshold: 10\n      - type: slack\n        failure-threshold: 15\n```\n\nOf course, you can also mix alert types:\n```yaml\nalerting:\n  slack:\n    webhook-url: \"https://hooks.slack.com/services/**********/**********/**********\"\n    default-alert:\n      failure-threshold: 3\n  pagerduty:\n    integration-key: \"********************************\"\n    default-alert:\n      failure-threshold: 5\n\nendpoints:\n  - name: endpoint-1\n    url: \"https://example.org\"\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: slack\n      - type: pagerduty\n\n  - name: endpoint-2\n    url: \"https://example.org\"\n    conditions:\n      - \"[STATUS] == 200\"\n    alerts:\n      - type: slack\n      - type: pagerduty\n```\n\n\n### Maintenance\nIf you have maintenance windows, you may not want to be annoyed by alerts.\nTo do that, you'll have to use the maintenance configuration:\n\n| Parameter              | Description                                                                                                                                                                                | Default       |\n|:-----------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------|\n| `maintenance.enabled`  | Whether the maintenance period is enabled                                                                                                                                                  | `true`        |\n| `maintenance.start`    | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`)                                                                                                               | Required `\"\"` |\n| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`)                                                                                                                                      | Required `\"\"` |\n| `maintenance.timezone` | Timezone of the maintenance window format (e.g. `Europe/Amsterdam`).<br />See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more info | `UTC`         |\n| `maintenance.every`    | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day                                                     | `[]`          |\n\nHere's an example:\n```yaml\nmaintenance:\n  start: 23:00\n  duration: 1h\n  timezone: \"Europe/Amsterdam\"\n  every: [Monday, Thursday]\n```\nNote that you can also specify each day on separate lines:\n```yaml\nmaintenance:\n  start: 23:00\n  duration: 1h\n  timezone: \"Europe/Amsterdam\"\n  every:\n    - Monday\n    - Thursday\n```\nYou can also specify maintenance windows on a per-endpoint basis:\n```yaml\nendpoints:\n  - name: endpoint-1\n    url: \"https://example.org\"\n    maintenance-windows:\n      - start: \"07:30\"\n        duration: 40m\n        timezone: \"Europe/Berlin\"\n      - start: \"14:30\"\n        duration: 1h\n        timezone: \"Europe/Berlin\"\n```\n\n\n### Security\n| Parameter        | Description                  | Default |\n|:-----------------|:-----------------------------|:--------|\n| `security`       | Security configuration       | `{}`    |\n| `security.basic` | HTTP Basic configuration     | `{}`    |\n| `security.oidc`  | OpenID Connect configuration | `{}`    |\n\n\n#### Basic Authentication\n| Parameter                               | Description                                                                        | Default       |\n|:----------------------------------------|:-----------------------------------------------------------------------------------|:--------------|\n| `security.basic`                        | HTTP Basic configuration                                                           | `{}`          |\n| `security.basic.username`               | Username for Basic authentication.                                                 | Required `\"\"` |\n| `security.basic.password-bcrypt-base64` | Password hashed with Bcrypt and then encoded with base64 for Basic authentication. | Required `\"\"` |\n\nThe example below will require that you authenticate with the username `john.doe` and the password `hunter2`:\n```yaml\nsecurity:\n  basic:\n    username: \"john.doe\"\n    password-bcrypt-base64: \"JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu\"\n```\n\n> ⚠ Make sure to carefully select the cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,\n> and basic auth verifies the password against the hash on every request. As of 2023-01-06, I suggest a cost of 9.\n\n\n#### OIDC\n| Parameter                        | Description                                                    | Default       |\n|:---------------------------------|:---------------------------------------------------------------|:--------------|\n| `security.oidc`                  | OpenID Connect configuration                                   | `{}`          |\n| `security.oidc.issuer-url`       | Issuer URL                                                     | Required `\"\"` |\n| `security.oidc.redirect-url`     | Redirect URL. Must end with `/authorization-code/callback`     | Required `\"\"` |\n| `security.oidc.client-id`        | Client id                                                      | Required `\"\"` |\n| `security.oidc.client-secret`    | Client secret                                                  | Required `\"\"` |\n| `security.oidc.scopes`           | Scopes to request. The only scope you need is `openid`.        | Required `[]` |\n| `security.oidc.allowed-subjects` | List of subjects to allow. If empty, all subjects are allowed. | `[]`          |\n| `security.oidc.session-ttl`      | Session time-to-live (e.g. `8h`, `1h30m`, `2h`).               | `8h`          |\n\n```yaml\nsecurity:\n  oidc:\n    issuer-url: \"https://example.okta.com\"\n    redirect-url: \"https://status.example.com/authorization-code/callback\"\n    client-id: \"123456789\"\n    client-secret: \"abcdefghijk\"\n    scopes: [\"openid\"]\n    # You may optionally specify a list of allowed subjects. If this is not specified, all subjects will be allowed.\n    #allowed-subjects: [\"johndoe@example.com\"]\n    # You may optionally specify a session time-to-live. If this is not specified, defaults to 8 hours.\n    #session-ttl: 8h\n```\n\nConfused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).\n\n\n### TLS Encryption\nGatus supports basic encryption with TLS. To enable this, certificate files in PEM format have to be provided.\n\nThe example below shows an example configuration which makes gatus respond on port 4443 to HTTPS requests:\n```yaml\nweb:\n  port: 4443\n  tls:\n    certificate-file: \"certificate.crt\"\n    private-key-file: \"private.key\"\n```\n\n\n### Metrics\nTo enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics`\nendpoint on the same port your application is configured to run on (`web.port`).\n\n| Metric name                                  | Type    | Description                                                                | Labels                          | Relevant endpoint types |\n|:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------|\n| gatus_results_total                          | counter | Number of results per endpoint per success state                           | key, group, name, type, success | All                     |\n| gatus_results_code_total                     | counter | Total number of results by code                                            | key, group, name, type, code    | DNS, HTTP               |\n| gatus_results_connected_total                | counter | Total number of results in which a connection was successfully established | key, group, name, type          | All                     |\n| gatus_results_duration_seconds               | gauge   | Duration of the request in seconds                                         | key, group, name, type          | All                     |\n| gatus_results_certificate_expiration_seconds | gauge   | Number of seconds until the certificate expires                            | key, group, name, type          | HTTP, STARTTLS          |\n| gatus_results_domain_expiration_seconds      | gauge   | Number of seconds until the domains expires                                | key, group, name, type          | HTTP, STARTTLS          |\n| gatus_results_endpoint_success               | gauge   | Displays whether or not the endpoint was a success (0 failure, 1 success)  | key, group, name, type          | All                     |\n\nSee [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.\n\n#### Custom Labels\n\nYou can add custom labels to your endpoints’ Prometheus metrics by defining key–value pairs under the `extra-labels` field. For example:\n\n```yaml\nendpoints:\n  - name: front-end\n    group: core\n    url: \"https://twin.sh/health\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 150\"\n    extra-labels:\n      environment: staging\n```\n\n### Connectivity\n| Parameter                       | Description                                | Default       |\n|:--------------------------------|:-------------------------------------------|:--------------|\n| `connectivity`                  | Connectivity configuration                 | `{}`          |\n| `connectivity.checker`          | Connectivity checker configuration         | Required `{}` |\n| `connectivity.checker.target`   | Host to use for validating connectivity    | Required `\"\"` |\n| `connectivity.checker.interval` | Interval at which to validate connectivity | `1m`          |\n\nWhile Gatus is used to monitor other services, it is possible for Gatus itself to lose connectivity to the internet.\nIn order to prevent Gatus from reporting endpoints as unhealthy when Gatus itself is unhealthy, you may configure\nGatus to periodically check for internet connectivity.\n\nAll endpoint executions are skipped while the connectivity checker deems connectivity to be down.\n\n```yaml\nconnectivity:\n  checker:\n    target: 1.1.1.1:53\n    interval: 60s\n```\n\n\n### Remote instances (EXPERIMENTAL)\nThis feature allows you to retrieve endpoint statuses from a remote Gatus instance.\n\nThere are two main use cases for this:\n- You have multiple Gatus instances running on different machines, and you wish to visually expose the statuses through a single dashboard\n- You have one or more Gatus instances that are not publicly accessible (e.g. behind a firewall), and you wish to retrieve\n\nThis is an experimental feature. It may be removed or updated in a breaking manner at any time. Furthermore,\nthere 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).\nUse at your own risk.\n\n| Parameter                          | Description                                  | Default       |\n|:-----------------------------------|:---------------------------------------------|:--------------|\n| `remote`                           | Remote configuration                         | `{}`          |\n| `remote.instances`                 | List of remote instances                     | Required `[]` |\n| `remote.instances.endpoint-prefix` | String to prefix all endpoint names with     | `\"\"`          |\n| `remote.instances.url`             | URL from which to retrieve endpoint statuses | Required `\"\"` |\n\n```yaml\nremote:\n  instances:\n    - endpoint-prefix: \"status.example.org-\"\n      url: \"https://status.example.org/api/v1/endpoints/statuses\"\n```\n\n\n## Deployment\nMany examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus.\n\n\n### Docker\nTo run Gatus locally with Docker:\n```console\ndocker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable\n```\n\nOther than using one of the examples provided in the [.examples](.examples) folder, you can also try it out locally by\ncreating a configuration file, we'll call it `config.yaml` for this example, and running the following\ncommand:\n```console\ndocker run -p 8080:8080 --mount type=bind,source=\"$(pwd)\"/config.yaml,target=/config/config.yaml --name gatus ghcr.io/twin/gatus:stable\n```\n\nIf you're on Windows, replace `\"$(pwd)\"` by the absolute path to your current directory, e.g.:\n```console\ndocker 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\n```\n\nTo build the image locally:\n```console\ndocker build . -t ghcr.io/twin/gatus:stable\n```\n\n\n### Helm Chart\n[Helm](https://helm.sh) must be installed to use the chart.\nPlease refer to Helm's [documentation](https://helm.sh/docs/) to get started.\n\nOnce Helm is set up properly, add the repository as follows:\n\n```console\nhelm repo add twin https://twin.github.io/helm-charts\nhelm repo update\nhelm install gatus twin/gatus\n```\n\nTo get more details, please check [chart's configuration](https://github.com/TwiN/helm-charts/blob/master/charts/gatus/README.md).\n\n\n### Terraform\n\n#### Kubernetes\n\nGatus can be deployed on Kubernetes using Terraform by using the following module: [terraform-kubernetes-gatus](https://github.com/TwiN/terraform-kubernetes-gatus).\n\n## Running the tests\n```console\ngo test -v ./...\n```\n\n\n## Using in Production\nSee the [Deployment](#deployment) section.\n\n\n## FAQ\n### Sending a GraphQL request\nBy setting `endpoints[].graphql` to true, the body will automatically be wrapped by the standard GraphQL `query` parameter.\n\nFor instance, the following configuration:\n```yaml\nendpoints:\n  - name: filter-users-by-gender\n    url: http://localhost:8080/playground\n    method: POST\n    graphql: true\n    body: |\n      {\n        users(gender: \"female\") {\n          id\n          name\n          gender\n          avatar\n        }\n      }\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].data.users[0].gender == female\"\n```\n\nwill send a `POST` request to `http://localhost:8080/playground` with the following body:\n```json\n{\"query\":\"      {\\n        users(gender: \\\"female\\\") {\\n          id\\n          name\\n          gender\\n          avatar\\n        }\\n      }\"}\n```\n\n\n### Recommended interval\nTo ensure that Gatus provides reliable and accurate results (i.e. response time), Gatus limits the number of\nendpoints/suites that can be evaluated at the same time.\nIn other words, even if you have multiple endpoints with the same interval, they are not guaranteed to run at the same time.\n\nThe number of concurrent evaluations is determined by the `concurrency` configuration parameter, which defaults to `3`.\n\nYou can test this yourself by running Gatus with several endpoints configured with a very short, unrealistic interval,\nsuch as 1ms. You'll notice that the response time does not fluctuate - that is because while endpoints are evaluated on\ndifferent goroutines, there's a semaphore that controls how many endpoints/suites from running at the same time.\n\nUnfortunately, there is a drawback. If you have a lot of endpoints, including some that are very slow or prone to timing out\n(the default timeout is 10s), those slow evaluations may prevent other endpoints/suites from being evaluated.\n\nThe interval does not include the duration of the request itself, which means that if an endpoint has an interval of 30s\nand the request takes 2s to complete, the timestamp between two evaluations will be 32s, not 30s.\n\nWhile this does not prevent Gatus' from performing health checks on all other endpoints, it may cause Gatus to be unable\nto respect the configured interval, for instance, assuming `concurrency` is set to `1`:\n- Endpoint A has an interval of 5s, and times out after 10s to complete\n- Endpoint B has an interval of 5s, and takes 1ms to complete\n- Endpoint B will be unable to run every 5s, because endpoint A's health evaluation takes longer than its interval\n\nTo sum it up, while Gatus can handle any interval you throw at it, you're better off having slow requests with\nhigher interval.\n\nAs a rule of thumb, I personally set the interval for more complex health checks to `5m` (5 minutes) and\nsimple health checks used for alerting (PagerDuty/Twilio) to `30s`.\n\n\n### Default timeouts\n| Endpoint type | Timeout |\n|:--------------|:--------|\n| HTTP          | 10s     |\n| TCP           | 10s     |\n| ICMP          | 10s     |\n\nTo modify the timeout, see [Client configuration](#client-configuration).\n\n\n### Monitoring a TCP endpoint\nBy prefixing `endpoints[].url` with `tcp://`, you can monitor TCP endpoints at a very basic level:\n```yaml\nendpoints:\n  - name: redis\n    url: \"tcp://127.0.0.1:6379\"\n    interval: 30s\n    conditions:\n      - \"[CONNECTED] == true\"\n```\nIf `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.\n\nPlaceholder `[STATUS]` as well as the fields `endpoints[].headers`,\n`endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.\n\nThis works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.).\n\n> 📝 `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's\n> something at the given address listening to the given port, and that a connection to that address was successfully\n> established.\n\n\n### Monitoring a UDP endpoint\nBy prefixing `endpoints[].url` with `udp://`, you can monitor UDP endpoints at a very basic level:\n```yaml\nendpoints:\n  - name: example\n    url: \"udp://example.org:80\"\n    conditions:\n      - \"[CONNECTED] == true\"\n```\n\nIf `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.\n\nPlaceholder `[STATUS]` as well as the fields `endpoints[].headers`,\n`endpoints[].method` and `endpoints[].graphql` are not supported for UDP endpoints.\n\nThis works for UDP based application.\n\n\n### Monitoring a SCTP endpoint\nBy prefixing `endpoints[].url` with `sctp://`, you can monitor Stream Control Transmission Protocol (SCTP) endpoints at a very basic level:\n```yaml\nendpoints:\n  - name: example\n    url: \"sctp://127.0.0.1:38412\"\n    conditions:\n      - \"[CONNECTED] == true\"\n```\n\nPlaceholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`,\n`endpoints[].method` and `endpoints[].graphql` are not supported for SCTP endpoints.\n\nThis works for SCTP based application.\n\n\n### Monitoring a WebSocket endpoint\nBy prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints:\n```yaml\nendpoints:\n  - name: example\n    url: \"wss://echo.websocket.org/\"\n    body: \"status\"\n    conditions:\n      - \"[CONNECTED] == true\"\n      - \"[BODY] == pat(*served by*)\"\n```\n\nThe `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`\nshows whether the connection was successfully established. You can use Go template\nsyntax.\n\n\n### Monitoring an endpoint using gRPC\nYou can monitor gRPC services by prefixing `endpoints[].url` with `grpc://` or `grpcs://`.\nGatus executes the standard `grpc.health.v1.Health/Check` RPC against the target.\n\n```yaml\nendpoints:\n  - name: my-grpc\n    url: grpc://localhost:50051\n    interval: 30s\n    conditions:\n      - \"[CONNECTED] == true\"\n      - \"[BODY].status == SERVING\"  # BODY is read only when referenced\n    client:\n      timeout: 5s\n```\n\nFor TLS-enabled servers, use `grpcs://` and configure client TLS if necessary:\n\n```yaml\nendpoints:\n  - name: my-grpcs\n    url: grpcs://example.com:443\n    conditions:\n      - \"[CONNECTED] == true\"\n      - \"[BODY].status == SERVING\"\n    client:\n      timeout: 5s\n      insecure: false          # set true to skip cert verification (not recommended)\n      tls:\n        certificate-file: /path/to/cert.pem      # optional mTLS client cert\n        private-key-file: /path/to/key.pem       # optional mTLS client key\n```\n\nNotes:\n- The health check targets the default service (`service: \"\"`). Support for a custom service name can be added later if needed.\n- The response body is exposed as a minimal JSON object like `{\"status\":\"SERVING\"}` only when required by conditions or suite store mappings.\n- Timeouts, custom DNS resolvers and SSH tunnels are honored via the existing [`client` configuration](#client-configuration).\n\n\n### Monitoring an endpoint using ICMP\nBy prefixing `endpoints[].url` with `icmp://`, you can monitor endpoints at a very basic level using ICMP, or more\ncommonly known as \"ping\" or \"echo\":\n```yaml\nendpoints:\n  - name: ping-example\n    url: \"icmp://example.com\"\n    conditions:\n      - \"[CONNECTED] == true\"\n```\n\nOnly the placeholders `[CONNECTED]`, `[IP]` and `[RESPONSE_TIME]` are supported for endpoints of type ICMP.\nYou can specify a domain prefixed by `icmp://`, or an IP address prefixed by `icmp://`.\n\nIf you run Gatus on Linux, please read the Linux section on [https://github.com/prometheus-community/pro-bing#linux]\nif you encounter any problems.\n\nPrior to `v5.31.0`, some environment setups required adding `CAP_NET_RAW` capabilities to allow pings to work.\nAs 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.\n\n\n### Monitoring an endpoint using DNS queries\nDefining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS:\n```yaml\nendpoints:\n  - name: example-dns-query\n    url: \"8.8.8.8\" # Address of the DNS server to use\n    dns:\n      query-name: \"example.com\"\n      query-type: \"A\"\n    conditions:\n      - \"[BODY] == 93.184.215.14\"\n      - \"[DNS_RCODE] == NOERROR\"\n```\n\nThere are two placeholders that can be used in the conditions for endpoints of type DNS:\n- The placeholder `[BODY]` resolves to the output of the query. For instance, a query of type `A` would return an IPv4.\n- The placeholder `[DNS_RCODE]` resolves to the name associated to the response code returned by the query, such as\n`NOERROR`, `FORMERR`, `SERVFAIL`, `NXDOMAIN`, etc.\n\n\n### Monitoring an endpoint using SSH\nYou can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:\n```yaml\nendpoints:\n  # Password-based SSH example\n  - name: ssh-example-password\n    url: \"ssh://example.com:22\" # port is optional. Default is 22.\n    ssh:\n      username: \"username\"\n      password: \"password\"\n    body: |\n      {\n        \"command\": \"echo '{\\\"memory\\\": {\\\"used\\\": 512}}'\"\n      }\n    interval: 1m\n    conditions:\n      - \"[CONNECTED] == true\"\n      - \"[STATUS] == 0\"\n      - \"[BODY].memory.used > 500\"\n\n  # Key-based SSH example\n  - name: ssh-example-key\n    url: \"ssh://example.com:22\" # port is optional. Default is 22.\n    ssh:\n      username: \"username\"\n      private-key: |\n        -----BEGIN RSA PRIVATE KEY-----\n        TESTRSAKEY...\n        -----END RSA PRIVATE KEY-----\n    interval: 1m\n    conditions:\n      - \"[CONNECTED] == true\"\n      - \"[STATUS] == 0\"\n```\n\nyou can also use no authentication to monitor the endpoint by not specifying the username,\npassword and private key fields.\n\n```yaml\nendpoints:\n  - name: ssh-example\n    url: \"ssh://example.com:22\" # port is optional. Default is 22.\n    ssh:\n      username: \"\"\n      password: \"\"\n      private-key: \"\"\n\n    interval: 1m\n    conditions:\n      - \"[CONNECTED] == true\"\n      - \"[STATUS] == 0\"\n```\n\nThe following placeholders are supported for endpoints of type SSH:\n- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise\n- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)\n- `[BODY]` resolves to the stdout output of the command executed on the remote server\n- `[IP]` resolves to the IP address of the server\n- `[RESPONSE_TIME]` resolves to the time it took to establish the connection and execute the command\n\n\n### Monitoring an endpoint using STARTTLS\nIf you have an email server that you want to ensure there are no problems with, monitoring it through STARTTLS\nwill serve as a good initial indicator:\n```yaml\nendpoints:\n  - name: starttls-smtp-example\n    url: \"starttls://smtp.gmail.com:587\"\n    interval: 30m\n    client:\n      timeout: 5s\n    conditions:\n      - \"[CONNECTED] == true\"\n      - \"[CERTIFICATE_EXPIRATION] > 48h\"\n```\n\n\n### Monitoring an endpoint using TLS\nMonitoring endpoints using SSL/TLS encryption, such as LDAP over TLS, can help detect certificate expiration:\n```yaml\nendpoints:\n  - name: tls-ldaps-example\n    url: \"tls://ldap.example.com:636\"\n    interval: 30m\n    client:\n      timeout: 5s\n    conditions:\n      - \"[CONNECTED] == true\"\n      - \"[CERTIFICATE_EXPIRATION] > 48h\"\n```\n\nIf `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.\n\nPlaceholder `[STATUS]` as well as the fields `endpoints[].headers`,\n`endpoints[].method` and `endpoints[].graphql` are not supported for TLS endpoints.\n\n\n### Monitoring domain expiration\nYou can monitor the expiration of a domain with all endpoint types except for DNS by using the `[DOMAIN_EXPIRATION]`\nplaceholder:\n```yaml\nendpoints:\n  - name: check-domain-and-certificate-expiration\n    url: \"https://example.org\"\n    interval: 1h\n    conditions:\n      - \"[DOMAIN_EXPIRATION] > 720h\"\n      - \"[CERTIFICATE_EXPIRATION] > 240h\"\n```\n\n> ⚠ 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\n> [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`).\n> To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from\n> using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`.\n\n\n### Concurrency\nBy 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.\n\nYou can configure the concurrency level using the `concurrency` parameter:\n\n```yaml\n# Allow 10 endpoints/suites to be monitored concurrently\nconcurrency: 10\n\n# Allow unlimited concurrent monitoring\nconcurrency: 0\n\n# Use default concurrency (3)\n# concurrency: 3\n```\n\n**Important considerations:**\n- Higher concurrency can improve monitoring performance when you have many endpoints\n- Conditions using the `[RESPONSE_TIME]` placeholder may be less accurate with very high concurrency due to system resource contention\n- Set to `0` for unlimited concurrency (equivalent to the deprecated `disable-monitoring-lock: true`)\n\n**Use cases for higher concurrency:**\n- You have a large number of endpoints to monitor\n- You want to monitor endpoints at very short intervals (< 5s)\n- You're using Gatus for load testing scenarios\n\n**Legacy configuration:**\nThe `disable-monitoring-lock` parameter is deprecated but still supported for backward compatibility. It's equivalent to setting `concurrency: 0`.\n\n\n### Reloading configuration on the fly\nFor the sake of convenience, Gatus automatically reloads the configuration on the fly if the loaded configuration file\nis updated while Gatus is running.\n\nBy default, the application will exit if the updating configuration is invalid, but you can configure\nGatus to continue running if the configuration file is updated with an invalid configuration by\nsetting `skip-invalid-config-update` to `true`.\n\nKeep in mind that it is in your best interest to ensure the validity of the configuration file after each update you\napply to the configuration file while Gatus is running by looking at the log and making sure that you do not see the\nfollowing message:\n```\nThe configuration file was updated, but it is not valid. The old configuration will continue being used.\n```\nFailure to do so may result in Gatus being unable to start if the application is restarted for whatever reason.\n\nI recommend not setting `skip-invalid-config-update` to `true` to avoid a situation like this, but the choice is yours\nto make.\n\n**If you are not using a file storage**, updating the configuration while Gatus is running is effectively\nthe same as restarting the application.\n\n> 📝 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).\n\n\n### Endpoint groups\nEndpoint groups are used for grouping multiple endpoints together on the dashboard.\n\n```yaml\nendpoints:\n  - name: frontend\n    group: core\n    url: \"https://example.org/\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n\n  - name: backend\n    group: core\n    url: \"https://example.org/\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n\n  - name: monitoring\n    group: internal\n    url: \"https://example.org/\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n\n  - name: nas\n    group: internal\n    url: \"https://example.org/\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n\n  - name: random endpoint that is not part of a group\n    url: \"https://example.org/\"\n    interval: 5m\n    conditions:\n      - \"[STATUS] == 200\"\n```\n\nThe configuration above will result in a dashboard that looks like this when sorting by group:\n\n![Gatus Endpoint Groups](.github/assets/endpoint-groups.jpg)\n\n\n### How do I sort by group by default?\nSet `ui.default-sort-by` to `group` in the configuration file:\n```yaml\nui:\n  default-sort-by: group\n```\nNote that if a user has already sorted the dashboard by a different field, the default sort will not be applied unless the user\nclears their browser's localstorage.\n\n\n### Exposing Gatus on a custom path\nCurrently, 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/`.\n\nFor more information, see https://github.com/TwiN/gatus/issues/88.\n\n\n### Exposing Gatus on a custom port\nBy default, Gatus is exposed on port `8080`, but you may specify a different port by setting the `web.port` parameter:\n```yaml\nweb:\n  port: 8081\n```\n\nIf you're using a PaaS like Heroku that doesn't let you set a custom port and exposes it through an environment\nvariable instead see [Use environment variables in config files](#use-environment-variables-in-config-files).\n\n### Use environment variables in config files\n\nYou can use environment variables directly in the configuration file which will be substituted from the environment:\n```yaml\nweb:\n  port: ${PORT}\n\nui:\n  title: $TITLE\n```\n⚠️ When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.\n\n### Configuring a startup delay\nIf, 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.\n\n\n### Keeping your configuration small\nWhile not specific to Gatus, you can leverage YAML anchors to create a default configuration.\nIf you have a large configuration file, this should help you keep things clean.\n\n<details>\n  <summary>Example</summary>\n\n```yaml\ndefault-endpoint: &defaults\n  group: core\n  interval: 5m\n  client:\n    insecure: true\n    timeout: 30s\n  conditions:\n    - \"[STATUS] == 200\"\n\nendpoints:\n  - name: anchor-example-1\n    <<: *defaults               # This will merge the configuration under &defaults with this endpoint\n    url: \"https://example.org\"\n\n  - name: anchor-example-2\n    <<: *defaults\n    group: example              # This will override the group defined in &defaults\n    url: \"https://example.com\"\n\n  - name: anchor-example-3\n    <<: *defaults\n    url: \"https://twin.sh/health\"\n    conditions:                # This will override the conditions defined in &defaults\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n```\n</details>\n\n\n### Proxy client configuration\nYou can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration.\n\n```yaml\nendpoints:\n  - name: website\n    url: \"https://twin.sh/health\"\n    client:\n      proxy-url: http://proxy.example.com:8080\n    conditions:\n      - \"[STATUS] == 200\"\n```\n\n\n### How to fix 431 Request Header Fields Too Large error\nDepending on where your environment is deployed and what kind of middleware or reverse proxy sits in front of Gatus,\nyou may run into this issue. This could be because the request headers are too large, e.g. big cookies.\n\nBy default, `web.read-buffer-size` is set to `8192`, but increasing this value like so will increase the read buffer size:\n```yaml\nweb:\n  read-buffer-size: 32768\n```\n\n### Badges\n#### Uptime\n![Uptime 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/1h/badge.svg)\n![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg)\n![Uptime 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/7d/badge.svg)\n![Uptime 30d](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/30d/badge.svg)\n\nGatus can automatically generate an SVG badge for one of your monitored endpoints.\nThis allows you to put badges in your individual applications' README or even create your own status page if you\ndesire.\n\nThe path to generate a badge is the following:\n```\n/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg\n```\nWhere:\n- `{duration}` is `30d`, `7d`, `24h` or `1h`\n- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.\n\nFor instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,\nthe URL would look like this:\n```\nhttps://example.com/api/v1/endpoints/core_frontend/uptimes/7d/badge.svg\n```\nIf you want to display an endpoint that is not part of a group, you must leave the group value empty:\n```\nhttps://example.com/api/v1/endpoints/_frontend/uptimes/7d/badge.svg\n```\nExample:\n```\n![Uptime 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/uptimes/24h/badge.svg)\n```\nIf you'd like to see a visual example of each badge available, you can simply navigate to the endpoint's detail page.\n\n\n#### Health\n![Health](https://status.twin.sh/api/v1/endpoints/core_blog-external/health/badge.svg)\n\nThe path to generate a badge is the following:\n```\n/api/v1/endpoints/{key}/health/badge.svg\n```\nWhere:\n- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.\n\nFor instance, if you want the current status of the endpoint `frontend` in the group `core`,\nthe URL would look like this:\n```\nhttps://example.com/api/v1/endpoints/core_frontend/health/badge.svg\n```\n\n\n#### Health (Shields.io)\n![Health](https://img.shields.io/endpoint?url=https%3A%2F%2Fstatus.twin.sh%2Fapi%2Fv1%2Fendpoints%2Fcore_blog-external%2Fhealth%2Fbadge.shields)\n\nThe path to generate a badge is the following:\n```\n/api/v1/endpoints/{key}/health/badge.shields\n```\nWhere:\n- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.\n\nFor instance, if you want the current status of the endpoint `frontend` in the group `core`,\nthe URL would look like this:\n```\nhttps://example.com/api/v1/endpoints/core_frontend/health/badge.shields\n```\n\nSee more information about the Shields.io badge endpoint [here](https://shields.io/badges/endpoint-badge).\n\n\n#### Response time\n![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg)\n![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)\n![Response time 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/7d/badge.svg)\n![Response time 30d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/30d/badge.svg)\n\nThe endpoint to generate a badge is the following:\n```\n/api/v1/endpoints/{key}/response-times/{duration}/badge.svg\n```\nWhere:\n- `{duration}` is `30d`, `7d`, `24h` or `1h`\n- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.\n\n#### Response time (chart)\n![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/chart.svg)\n![Response time 7d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/7d/chart.svg)\n![Response time 30d](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/30d/chart.svg)\n\nThe endpoint to generate a response time chart is the following:\n```\n/api/v1/endpoints/{key}/response-times/{duration}/chart.svg\n```\nWhere:\n- `{duration}` is `30d`, `7d`, or `24h`\n- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.\n\n##### How to change the color thresholds of the response time badge\nTo change the response time badges' threshold, a corresponding configuration can be added to an endpoint.\nThe values in the array correspond to the levels [Awesome, Great, Good, Passable, Bad]\nAll five values must be given in milliseconds (ms).\n\n```yaml\nendpoints:\n- name: nas\n  group: internal\n  url: \"https://example.org/\"\n  interval: 5m\n  conditions:\n    - \"[STATUS] == 200\"\n  ui:\n    badge:\n      response-time:\n        thresholds: [550, 850, 1350, 1650, 1750]\n```\n\n\n### API\nGatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history.\n\nAll endpoints are available via a GET request to the following endpoint:\n```\n/api/v1/endpoints/statuses\n````\nExample: https://status.twin.sh/api/v1/endpoints/statuses\n\nSpecific endpoints can also be queried by using the following pattern:\n```\n/api/v1/endpoints/{group}_{endpoint}/statuses\n```\nExample: https://status.twin.sh/api/v1/endpoints/core_blog-home/statuses\n\nGzip compression will be used if the `Accept-Encoding` HTTP header contains `gzip`.\n\nThe API will return a JSON payload with the `Content-Type` response header set to `application/json`.\nNo such header is required to query the API.\n\n\n#### Interacting with the API programmatically\nSee [TwiN/gatus-sdk](https://github.com/TwiN/gatus-sdk)\n\n\n#### Raw Data\nGatus exposes the raw data for one of your monitored endpoints.\nThis 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.\n\n##### Uptime\nThe path to get raw uptime data for an endpoint is:\n```\n/api/v1/endpoints/{key}/uptimes/{duration}\n```\nWhere:\n- `{duration}` is `30d`, `7d`, `24h` or `1h`\n- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.\n\nFor 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:\n```\nhttps://example.com/api/v1/endpoints/core_frontend/uptimes/24h\n```\n\n##### Response Time\nThe path to get raw response time data for an endpoint is:\n```\n/api/v1/endpoints/{key}/response-times/{duration}\n```\nWhere:\n- `{duration}` is `30d`, `7d`, `24h` or `1h`\n- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.`, `#`, `+` and `&` replaced by `-`.\n\nFor 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:\n```\nhttps://example.com/api/v1/endpoints/core_frontend/response-times/24h\n```\n\n\n### Installing as binary\nYou can download Gatus as a binary using the following command:\n```\ngo install github.com/TwiN/gatus/v5@latest\n```\n\n\n### High level design overview\n![Gatus diagram](.github/assets/gatus-diagram.jpg)\n"
  },
  {
    "path": "alerting/alert/alert.go",
    "content": "package alert\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/logr\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\t// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character\n\tErrAlertWithInvalidDescription = errors.New(\"alert description must not have \\\" or \\\\\")\n\n\tErrAlertWithInvalidMinimumReminderInterval = errors.New(\"minimum-reminder-interval must be either omitted or be at least 5m\")\n)\n\n// Alert is endpoint.Endpoint's alert configuration\ntype Alert struct {\n\t// Type of alert (required)\n\tType Type `yaml:\"type\"`\n\n\t// Enabled defines whether the alert is enabled\n\t//\n\t// Use Alert.IsEnabled() to retrieve the value of this field.\n\t//\n\t// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value\n\t// or not for provider.ParseWithDefaultAlert to work.\n\tEnabled *bool `yaml:\"enabled,omitempty\"`\n\n\t// FailureThreshold is the number of failures in a row needed before triggering the alert\n\tFailureThreshold int `yaml:\"failure-threshold\"`\n\n\t// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved\n\tSuccessThreshold int `yaml:\"success-threshold\"`\n\n\t// MinimumReminderInterval is the interval between reminders\n\tMinimumReminderInterval time.Duration `yaml:\"minimum-reminder-interval,omitempty\"`\n\n\t// Description of the alert. Will be included in the alert sent.\n\t//\n\t// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value\n\t// or not for provider.ParseWithDefaultAlert to work.\n\tDescription *string `yaml:\"description,omitempty\"`\n\n\t// SendOnResolved defines whether to send a second notification when the issue has been resolved\n\t//\n\t// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value\n\t// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer\n\tSendOnResolved *bool `yaml:\"send-on-resolved,omitempty\"`\n\n\t// ProviderOverride is an optional field that can be used to override the provider's configuration\n\t// It is freeform so that it can be used for any provider-specific configuration.\n\tProviderOverride map[string]any `yaml:\"provider-override,omitempty\"`\n\n\t// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve\n\t// ongoing/triggered incidents\n\tResolveKey string `yaml:\"-\"`\n\n\t// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value\n\t// should be set back to false. It is used to prevent the same alert from going out twice.\n\t//\n\t// This value should only be modified if the provider.AlertProvider's Send function does not return an error for an\n\t// alert that hasn't been triggered yet. This doubles as a lazy retry. The reason why this behavior isn't also\n\t// applied for alerts that are already triggered and has become \"healthy\" again is to prevent a case where, for\n\t// some reason, the alert provider always returns errors when trying to send the resolved notification\n\t// (SendOnResolved).\n\tTriggered bool `yaml:\"-\"`\n}\n\n// ValidateAndSetDefaults validates the alert's configuration and sets the default value of fields that have one\nfunc (alert *Alert) ValidateAndSetDefaults() error {\n\tif alert.FailureThreshold <= 0 {\n\t\talert.FailureThreshold = 3\n\t}\n\tif alert.SuccessThreshold <= 0 {\n\t\talert.SuccessThreshold = 2\n\t}\n\tif alert.MinimumReminderInterval != 0 && alert.MinimumReminderInterval < 5*time.Minute {\n\t\treturn ErrAlertWithInvalidMinimumReminderInterval\n\t}\n\tif strings.ContainsAny(alert.GetDescription(), \"\\\"\\\\\") {\n\t\treturn ErrAlertWithInvalidDescription\n\t}\n\treturn nil\n}\n\n// GetDescription retrieves the description of the alert\nfunc (alert *Alert) GetDescription() string {\n\tif alert.Description == nil {\n\t\treturn \"\"\n\t}\n\treturn *alert.Description\n}\n\n// IsEnabled returns whether an alert is enabled or not\n// Returns true if not set\nfunc (alert *Alert) IsEnabled() bool {\n\tif alert.Enabled == nil {\n\t\treturn true\n\t}\n\treturn *alert.Enabled\n}\n\n// IsSendingOnResolved returns whether an alert is sending on resolve or not\nfunc (alert *Alert) IsSendingOnResolved() bool {\n\tif alert.SendOnResolved == nil {\n\t\treturn false\n\t}\n\treturn *alert.SendOnResolved\n}\n\n// Checksum returns a checksum of the alert\n// Used to determine which persisted triggered alert should be deleted on application start\nfunc (alert *Alert) Checksum() string {\n\thash := sha256.New()\n\thash.Write([]byte(string(alert.Type) + \"_\" +\n\t\tstrconv.FormatBool(alert.IsEnabled()) + \"_\" +\n\t\tstrconv.FormatBool(alert.IsSendingOnResolved()) + \"_\" +\n\t\tstrconv.Itoa(alert.SuccessThreshold) + \"_\" +\n\t\tstrconv.Itoa(alert.FailureThreshold) + \"_\" +\n\t\talert.GetDescription()),\n\t)\n\treturn hex.EncodeToString(hash.Sum(nil))\n}\n\nfunc (alert *Alert) ProviderOverrideAsBytes() []byte {\n\tyamlBytes, err := yaml.Marshal(alert.ProviderOverride)\n\tif err != nil {\n\t\tlogr.Warnf(\"[alert.ProviderOverrideAsBytes] Failed to marshal alert override of type=%s as bytes: %v\", alert.Type, err)\n\t}\n\treturn yamlBytes\n}\n"
  },
  {
    "path": "alerting/alert/alert_test.go",
    "content": "package alert\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAlert_ValidateAndSetDefaults(t *testing.T) {\n\tinvalidDescription := \"\\\"\"\n\tscenarios := []struct {\n\t\tname                     string\n\t\talert                    Alert\n\t\texpectedError            error\n\t\texpectedSuccessThreshold int\n\t\texpectedFailureThreshold int\n\t}{\n\t\t{\n\t\t\tname: \"valid-empty\",\n\t\t\talert: Alert{\n\t\t\t\tDescription:      nil,\n\t\t\t\tFailureThreshold: 0,\n\t\t\t\tSuccessThreshold: 0,\n\t\t\t},\n\t\t\texpectedError:            nil,\n\t\t\texpectedFailureThreshold: 3,\n\t\t\texpectedSuccessThreshold: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-description\",\n\t\t\talert: Alert{\n\t\t\t\tDescription:      &invalidDescription,\n\t\t\t\tFailureThreshold: 10,\n\t\t\t\tSuccessThreshold: 5,\n\t\t\t},\n\t\t\texpectedError:            ErrAlertWithInvalidDescription,\n\t\t\texpectedFailureThreshold: 10,\n\t\t\texpectedSuccessThreshold: 5,\n\t\t},\n\t\t{\n\t\t\tname: \"valid-minimum-reminder-interval-0\",\n\t\t\talert: Alert{\n\t\t\t\tMinimumReminderInterval: 0,\n\t\t\t\tFailureThreshold:        10,\n\t\t\t\tSuccessThreshold:        5,\n\t\t\t},\n\t\t\texpectedError:            nil,\n\t\t\texpectedFailureThreshold: 10,\n\t\t\texpectedSuccessThreshold: 5,\n\t\t},\n\t\t{\n\t\t\tname: \"valid-minimum-reminder-interval-5m\",\n\t\t\talert: Alert{\n\t\t\t\tMinimumReminderInterval: 5 * time.Minute,\n\t\t\t\tFailureThreshold:        10,\n\t\t\t\tSuccessThreshold:        5,\n\t\t\t},\n\t\t\texpectedError:            nil,\n\t\t\texpectedFailureThreshold: 10,\n\t\t\texpectedSuccessThreshold: 5,\n\t\t},\n\t\t{\n\t\t\tname: \"valid-minimum-reminder-interval-10m\",\n\t\t\talert: Alert{\n\t\t\t\tMinimumReminderInterval: 10 * time.Minute,\n\t\t\t\tFailureThreshold:        10,\n\t\t\t\tSuccessThreshold:        5,\n\t\t\t},\n\t\t\texpectedError:            nil,\n\t\t\texpectedFailureThreshold: 10,\n\t\t\texpectedSuccessThreshold: 5,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-minimum-reminder-interval-1m\",\n\t\t\talert: Alert{\n\t\t\t\tMinimumReminderInterval: 1 * time.Minute,\n\t\t\t\tFailureThreshold:        10,\n\t\t\t\tSuccessThreshold:        5,\n\t\t\t},\n\t\t\texpectedError:            ErrAlertWithInvalidMinimumReminderInterval,\n\t\t\texpectedFailureThreshold: 10,\n\t\t\texpectedSuccessThreshold: 5,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-minimum-reminder-interval-1s\",\n\t\t\talert: Alert{\n\t\t\t\tMinimumReminderInterval: 1 * time.Second,\n\t\t\t\tFailureThreshold:        10,\n\t\t\t\tSuccessThreshold:        5,\n\t\t\t},\n\t\t\texpectedError:            ErrAlertWithInvalidMinimumReminderInterval,\n\t\t\texpectedFailureThreshold: 10,\n\t\t\texpectedSuccessThreshold: 5,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tif err := scenario.alert.ValidateAndSetDefaults(); !errors.Is(err, scenario.expectedError) {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.expectedError, err)\n\t\t\t}\n\t\t\tif scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {\n\t\t\t\tt.Errorf(\"expected success threshold %v, got %v\", scenario.expectedSuccessThreshold, scenario.alert.SuccessThreshold)\n\t\t\t}\n\t\t\tif scenario.alert.FailureThreshold != scenario.expectedFailureThreshold {\n\t\t\t\tt.Errorf(\"expected failure threshold %v, got %v\", scenario.expectedFailureThreshold, scenario.alert.FailureThreshold)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlert_IsEnabled(t *testing.T) {\n\tif !(&Alert{Enabled: nil}).IsEnabled() {\n\t\tt.Error(\"alert.IsEnabled() should've returned true, because Enabled was set to nil\")\n\t}\n\tif value := false; (&Alert{Enabled: &value}).IsEnabled() {\n\t\tt.Error(\"alert.IsEnabled() should've returned false, because Enabled was set to false\")\n\t}\n\tif value := true; !(&Alert{Enabled: &value}).IsEnabled() {\n\t\tt.Error(\"alert.IsEnabled() should've returned true, because Enabled was set to true\")\n\t}\n}\n\nfunc TestAlert_GetDescription(t *testing.T) {\n\tif (&Alert{Description: nil}).GetDescription() != \"\" {\n\t\tt.Error(\"alert.GetDescription() should've returned an empty string, because Description was set to nil\")\n\t}\n\tif value := \"description\"; (&Alert{Description: &value}).GetDescription() != value {\n\t\tt.Error(\"alert.GetDescription() should've returned false, because Description was set to 'description'\")\n\t}\n}\n\nfunc TestAlert_IsSendingOnResolved(t *testing.T) {\n\tif (&Alert{SendOnResolved: nil}).IsSendingOnResolved() {\n\t\tt.Error(\"alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil\")\n\t}\n\tif value := false; (&Alert{SendOnResolved: &value}).IsSendingOnResolved() {\n\t\tt.Error(\"alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false\")\n\t}\n\tif value := true; !(&Alert{SendOnResolved: &value}).IsSendingOnResolved() {\n\t\tt.Error(\"alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true\")\n\t}\n}\n\nfunc TestAlert_Checksum(t *testing.T) {\n\tdescription1, description2 := \"a\", \"b\"\n\tyes, no := true, false\n\tscenarios := []struct {\n\t\tname     string\n\t\talert    Alert\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"barebone\",\n\t\t\talert: Alert{\n\t\t\t\tType: TypeDiscord,\n\t\t\t},\n\t\t\texpected: \"fed0580e44ed5701dbba73afa1f14b2c53ca5a7b8067a860441c212916057fe3\",\n\t\t},\n\t\t{\n\t\t\tname: \"with-description-1\",\n\t\t\talert: Alert{\n\t\t\t\tType:        TypeDiscord,\n\t\t\t\tDescription: &description1,\n\t\t\t},\n\t\t\texpected: \"005f407ebe506e74a4aeb46f74c28b376debead7011e1b085da3840f72ba9707\",\n\t\t},\n\t\t{\n\t\t\tname: \"with-description-2\",\n\t\t\talert: Alert{\n\t\t\t\tType:        TypeDiscord,\n\t\t\t\tDescription: &description2,\n\t\t\t},\n\t\t\texpected: \"3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8\",\n\t\t},\n\t\t{\n\t\t\tname: \"with-description-2-and-enabled-false\",\n\t\t\talert: Alert{\n\t\t\t\tType:        TypeDiscord,\n\t\t\t\tEnabled:     &no,\n\t\t\t\tDescription: &description2,\n\t\t\t},\n\t\t\texpected: \"837945c2b4cd5e961db3e63e10c348d4f1c3446ba68cf5a48e35a1ae22cf0c22\",\n\t\t},\n\t\t{\n\t\t\tname: \"with-description-2-and-enabled-true\",\n\t\t\talert: Alert{\n\t\t\t\tType:        TypeDiscord,\n\t\t\t\tEnabled:     &yes, // it defaults to true if not set, but just to make sure\n\t\t\t\tDescription: &description2,\n\t\t\t},\n\t\t\texpected: \"3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8\",\n\t\t},\n\t\t{\n\t\t\tname: \"with-description-2-and-enabled-true-and-send-on-resolved-true\",\n\t\t\talert: Alert{\n\t\t\t\tType:           TypeDiscord,\n\t\t\t\tEnabled:        &yes,\n\t\t\t\tSendOnResolved: &yes,\n\t\t\t\tDescription:    &description2,\n\t\t\t},\n\t\t\texpected: \"bf1436995a880eb4a352c74c5dfee1f1b5ff6b9fc55aef9bf411b3631adfd80c\",\n\t\t},\n\t\t{\n\t\t\tname: \"with-description-2-and-failure-threshold-7\",\n\t\t\talert: Alert{\n\t\t\t\tType:             TypeSlack,\n\t\t\t\tFailureThreshold: 7,\n\t\t\t\tDescription:      &description2,\n\t\t\t},\n\t\t\texpected: \"8bd479e18bda393d4c924f5a0d962e825002168dedaa88b445e435db7bacffd3\",\n\t\t},\n\t\t{\n\t\t\tname: \"with-description-2-and-failure-threshold-9\",\n\t\t\talert: Alert{\n\t\t\t\tType:             TypeSlack,\n\t\t\t\tFailureThreshold: 9,\n\t\t\t\tDescription:      &description2,\n\t\t\t},\n\t\t\texpected: \"5abdfce5236e344996d264d526e769c07cb0d3d329a999769a1ff84b157ca6f1\",\n\t\t},\n\t\t{\n\t\t\tname: \"with-description-2-and-success-threshold-5\",\n\t\t\talert: Alert{\n\t\t\t\tType:             TypeSlack,\n\t\t\t\tSuccessThreshold: 7,\n\t\t\t\tDescription:      &description2,\n\t\t\t},\n\t\t\texpected: \"c0000e73626b80e212cfc24830de7094568f648e37f3e16f9e68c7f8ef75c34c\",\n\t\t},\n\t\t{\n\t\t\tname: \"with-description-2-and-success-threshold-1\",\n\t\t\talert: Alert{\n\t\t\t\tType:             TypeSlack,\n\t\t\t\tSuccessThreshold: 1,\n\t\t\t\tDescription:      &description2,\n\t\t\t},\n\t\t\texpected: \"5c28963b3a76104cfa4a0d79c89dd29ec596c8cfa4b1af210ec83d6d41587b5f\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tscenario.alert.ValidateAndSetDefaults()\n\t\t\tif checksum := scenario.alert.Checksum(); checksum != scenario.expected {\n\t\t\t\tt.Errorf(\"expected checksum %v, got %v\", scenario.expected, checksum)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/alert/type.go",
    "content": "package alert\n\n// Type is the type of the alert.\n// The value will generally be the name of the alert provider\ntype Type string\n\nconst (\n\t// TypeAWSSES is the Type for the awsses alerting provider\n\tTypeAWSSES Type = \"aws-ses\"\n\n\t// TypeClickUp is the Type for the clickup alerting provider\n\tTypeClickUp Type = \"clickup\"\n\n\t// TypeCustom is the Type for the custom alerting provider\n\tTypeCustom Type = \"custom\"\n\n\t// TypeDatadog is the Type for the datadog alerting provider\n\tTypeDatadog Type = \"datadog\"\n\n\t// TypeDiscord is the Type for the discord alerting provider\n\tTypeDiscord Type = \"discord\"\n\n\t// TypeEmail is the Type for the email alerting provider\n\tTypeEmail Type = \"email\"\n\n\t// TypeGitHub is the Type for the github alerting provider\n\tTypeGitHub Type = \"github\"\n\n\t// TypeGitLab is the Type for the gitlab alerting provider\n\tTypeGitLab Type = \"gitlab\"\n\n\t// TypeGitea is the Type for the gitea alerting provider\n\tTypeGitea Type = \"gitea\"\n\n\t// TypeGoogleChat is the Type for the googlechat alerting provider\n\tTypeGoogleChat Type = \"googlechat\"\n\n\t// TypeGotify is the Type for the gotify alerting provider\n\tTypeGotify Type = \"gotify\"\n\n\t// TypeHomeAssistant is the Type for the homeassistant alerting provider\n\tTypeHomeAssistant Type = \"homeassistant\"\n\n\t// TypeIFTTT is the Type for the ifttt alerting provider\n\tTypeIFTTT Type = \"ifttt\"\n\n\t// TypeIlert is the Type for the ilert alerting provider\n\tTypeIlert Type = \"ilert\"\n\n\t// TypeIncidentIO is the Type for the incident-io alerting provider\n\tTypeIncidentIO Type = \"incident-io\"\n\n\t// TypeLine is the Type for the line alerting provider\n\tTypeLine Type = \"line\"\n\n\t// TypeMatrix is the Type for the matrix alerting provider\n\tTypeMatrix Type = \"matrix\"\n\n\t// TypeMattermost is the Type for the mattermost alerting provider\n\tTypeMattermost Type = \"mattermost\"\n\n\t// TypeMessagebird is the Type for the messagebird alerting provider\n\tTypeMessagebird Type = \"messagebird\"\n\n\t// TypeNewRelic is the Type for the newrelic alerting provider\n\tTypeNewRelic Type = \"newrelic\"\n\n\t// TypeN8N is the Type for the n8n alerting provider\n\tTypeN8N Type = \"n8n\"\n\n\t// TypeNtfy is the Type for the ntfy alerting provider\n\tTypeNtfy Type = \"ntfy\"\n\n\t// TypeOpsgenie is the Type for the opsgenie alerting provider\n\tTypeOpsgenie Type = \"opsgenie\"\n\n\t// TypePagerDuty is the Type for the pagerduty alerting provider\n\tTypePagerDuty Type = \"pagerduty\"\n\n\t// TypePlivo is the Type for the plivo alerting provider\n\tTypePlivo Type = \"plivo\"\n\n\t// TypePushover is the Type for the pushover alerting provider\n\tTypePushover Type = \"pushover\"\n\n\t// TypeRocketChat is the Type for the rocketchat alerting provider\n\tTypeRocketChat Type = \"rocketchat\"\n\n\t// TypeSendGrid is the Type for the sendgrid alerting provider\n\tTypeSendGrid Type = \"sendgrid\"\n\n\t// TypeSignal is the Type for the signal alerting provider\n\tTypeSignal Type = \"signal\"\n\n\t// TypeSIGNL4 is the Type for the signl4 alerting provider\n\tTypeSIGNL4 Type = \"signl4\"\n\n\t// TypeSlack is the Type for the slack alerting provider\n\tTypeSlack Type = \"slack\"\n\n\t// TypeSplunk is the Type for the splunk alerting provider\n\tTypeSplunk Type = \"splunk\"\n\n\t// TypeSquadcast is the Type for the squadcast alerting provider\n\tTypeSquadcast Type = \"squadcast\"\n\n\t// TypeTeams is the Type for the teams alerting provider\n\tTypeTeams Type = \"teams\"\n\n\t// TypeTeamsWorkflows is the Type for the teams-workflows alerting provider\n\tTypeTeamsWorkflows Type = \"teams-workflows\"\n\n\t// TypeTelegram is the Type for the telegram alerting provider\n\tTypeTelegram Type = \"telegram\"\n\n\t// TypeTwilio is the Type for the twilio alerting provider\n\tTypeTwilio Type = \"twilio\"\n\n\t// TypeVonage is the Type for the vonage alerting provider\n\tTypeVonage Type = \"vonage\"\n\n\t// TypeWebex is the Type for the webex alerting provider\n\tTypeWebex Type = \"webex\"\n\n\t// TypeZapier is the Type for the zapier alerting provider\n\tTypeZapier Type = \"zapier\"\n\n\t// TypeZulip is the Type for the Zulip alerting provider\n\tTypeZulip Type = \"zulip\"\n)\n"
  },
  {
    "path": "alerting/config.go",
    "content": "package alerting\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/awsses\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/clickup\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/custom\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/datadog\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/discord\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/email\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/gitea\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/github\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/gitlab\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/googlechat\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/gotify\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/homeassistant\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ifttt\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ilert\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/incidentio\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/line\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/matrix\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/mattermost\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/messagebird\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/n8n\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/newrelic\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ntfy\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/opsgenie\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/pagerduty\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/plivo\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/pushover\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/rocketchat\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/sendgrid\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/signal\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/signl4\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/slack\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/splunk\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/squadcast\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/teams\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/telegram\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/twilio\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/vonage\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/webex\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/zapier\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/zulip\"\n\t\"github.com/TwiN/logr\"\n)\n\n// Config is the configuration for alerting providers\ntype Config struct {\n\t// AWSSimpleEmailService is the configuration for the aws-ses alerting provider\n\tAWSSimpleEmailService *awsses.AlertProvider `yaml:\"aws-ses,omitempty\"`\n\n\t// ClickUp is the configuration for the clickup alerting provider\n\tClickUp *clickup.AlertProvider `yaml:\"clickup,omitempty\"`\n\n\t// Custom is the configuration for the custom alerting provider\n\tCustom *custom.AlertProvider `yaml:\"custom,omitempty\"`\n\n\t// Datadog is the configuration for the datadog alerting provider\n\tDatadog *datadog.AlertProvider `yaml:\"datadog,omitempty\"`\n\n\t// Discord is the configuration for the discord alerting provider\n\tDiscord *discord.AlertProvider `yaml:\"discord,omitempty\"`\n\n\t// Email is the configuration for the email alerting provider\n\tEmail *email.AlertProvider `yaml:\"email,omitempty\"`\n\n\t// GitHub is the configuration for the github alerting provider\n\tGitHub *github.AlertProvider `yaml:\"github,omitempty\"`\n\n\t// GitLab is the configuration for the gitlab alerting provider\n\tGitLab *gitlab.AlertProvider `yaml:\"gitlab,omitempty\"`\n\n\t// Gitea is the configuration for the gitea alerting provider\n\tGitea *gitea.AlertProvider `yaml:\"gitea,omitempty\"`\n\n\t// GoogleChat is the configuration for the googlechat alerting provider\n\tGoogleChat *googlechat.AlertProvider `yaml:\"googlechat,omitempty\"`\n\n\t// Gotify is the configuration for the gotify alerting provider\n\tGotify *gotify.AlertProvider `yaml:\"gotify,omitempty\"`\n\n\t// HomeAssistant is the configuration for the homeassistant alerting provider\n\tHomeAssistant *homeassistant.AlertProvider `yaml:\"homeassistant,omitempty\"`\n\n\t// IFTTT is the configuration for the ifttt alerting provider\n\tIFTTT *ifttt.AlertProvider `yaml:\"ifttt,omitempty\"`\n\n\t// Ilert is the configuration for the ilert alerting provider\n\tIlert *ilert.AlertProvider `yaml:\"ilert,omitempty\"`\n\n\t// IncidentIO is the configuration for the incident-io alerting provider\n\tIncidentIO *incidentio.AlertProvider `yaml:\"incident-io,omitempty\"`\n\n\t// Line is the configuration for the line alerting provider\n\tLine *line.AlertProvider `yaml:\"line,omitempty\"`\n\n\t// Matrix is the configuration for the matrix alerting provider\n\tMatrix *matrix.AlertProvider `yaml:\"matrix,omitempty\"`\n\n\t// Mattermost is the configuration for the mattermost alerting provider\n\tMattermost *mattermost.AlertProvider `yaml:\"mattermost,omitempty\"`\n\n\t// Messagebird is the configuration for the messagebird alerting provider\n\tMessagebird *messagebird.AlertProvider `yaml:\"messagebird,omitempty\"`\n\n\t// NewRelic is the configuration for the newrelic alerting provider\n\tNewRelic *newrelic.AlertProvider `yaml:\"newrelic,omitempty\"`\n\n\t// N8N is the configuration for the n8n alerting provider\n\tN8N *n8n.AlertProvider `yaml:\"n8n,omitempty\"`\n\n\t// Ntfy is the configuration for the ntfy alerting provider\n\tNtfy *ntfy.AlertProvider `yaml:\"ntfy,omitempty\"`\n\n\t// Opsgenie is the configuration for the opsgenie alerting provider\n\tOpsgenie *opsgenie.AlertProvider `yaml:\"opsgenie,omitempty\"`\n\n\t// PagerDuty is the configuration for the pagerduty alerting provider\n\tPagerDuty *pagerduty.AlertProvider `yaml:\"pagerduty,omitempty\"`\n\n\t// Plivo is the configuration for the plivo alerting provider\n\tPlivo *plivo.AlertProvider `yaml:\"plivo,omitempty\"`\n\n\t// Pushover is the configuration for the pushover alerting provider\n\tPushover *pushover.AlertProvider `yaml:\"pushover,omitempty\"`\n\n\t// RocketChat is the configuration for the rocketchat alerting provider\n\tRocketChat *rocketchat.AlertProvider `yaml:\"rocketchat,omitempty\"`\n\n\t// SendGrid is the configuration for the sendgrid alerting provider\n\tSendGrid *sendgrid.AlertProvider `yaml:\"sendgrid,omitempty\"`\n\n\t// Signal is the configuration for the signal alerting provider\n\tSignal *signal.AlertProvider `yaml:\"signal,omitempty\"`\n\n\t// SIGNL4 is the configuration for the signl4 alerting provider\n\tSIGNL4 *signl4.AlertProvider `yaml:\"signl4,omitempty\"`\n\n\t// Slack is the configuration for the slack alerting provider\n\tSlack *slack.AlertProvider `yaml:\"slack,omitempty\"`\n\n\t// Splunk is the configuration for the splunk alerting provider\n\tSplunk *splunk.AlertProvider `yaml:\"splunk,omitempty\"`\n\n\t// Squadcast is the configuration for the squadcast alerting provider\n\tSquadcast *squadcast.AlertProvider `yaml:\"squadcast,omitempty\"`\n\n\t// Teams is the configuration for the teams alerting provider\n\tTeams *teams.AlertProvider `yaml:\"teams,omitempty\"`\n\n\t// TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector\n\tTeamsWorkflows *teamsworkflows.AlertProvider `yaml:\"teams-workflows,omitempty\"`\n\n\t// Telegram is the configuration for the telegram alerting provider\n\tTelegram *telegram.AlertProvider `yaml:\"telegram,omitempty\"`\n\n\t// Twilio is the configuration for the twilio alerting provider\n\tTwilio *twilio.AlertProvider `yaml:\"twilio,omitempty\"`\n\n\t// Vonage is the configuration for the vonage alerting provider\n\tVonage *vonage.AlertProvider `yaml:\"vonage,omitempty\"`\n\n\t// Webex is the configuration for the webex alerting provider\n\tWebex *webex.AlertProvider `yaml:\"webex,omitempty\"`\n\n\t// Zapier is the configuration for the zapier alerting provider\n\tZapier *zapier.AlertProvider `yaml:\"zapier,omitempty\"`\n\n\t// Zulip is the configuration for the zulip alerting provider\n\tZulip *zulip.AlertProvider `yaml:\"zulip,omitempty\"`\n}\n\n// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type\nfunc (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {\n\tentityType := reflect.TypeOf(config).Elem()\n\tfor i := 0; i < entityType.NumField(); i++ {\n\t\tfield := entityType.Field(i)\n\t\ttag := strings.Split(field.Tag.Get(\"yaml\"), \",\")[0]\n\t\tif tag == string(alertType) {\n\t\t\tfieldValue := reflect.ValueOf(config).Elem().Field(i)\n\t\t\tif fieldValue.IsNil() {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fieldValue.Interface().(provider.AlertProvider)\n\t\t}\n\t}\n\tlogr.Infof(\"[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s\", alertType)\n\treturn nil\n}\n\n// SetAlertingProviderToNil Sets an alerting provider to nil to avoid having to revalidate it every time an\n// alert of its corresponding type is sent.\nfunc (config *Config) SetAlertingProviderToNil(p provider.AlertProvider) {\n\tentityType := reflect.TypeOf(config).Elem()\n\tfor i := 0; i < entityType.NumField(); i++ {\n\t\tfield := entityType.Field(i)\n\t\tif field.Type == reflect.TypeOf(p) {\n\t\t\treflect.ValueOf(config).Elem().Field(i).Set(reflect.Zero(field.Type))\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/awsses/awsses.go",
    "content": "package awsses\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/config\"\n\t\"github.com/aws/aws-sdk-go-v2/credentials\"\n\t\"github.com/aws/aws-sdk-go-v2/service/ses\"\n\t\"github.com/aws/aws-sdk-go-v2/service/ses/types\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\tCharSet = \"UTF-8\"\n)\n\nvar (\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n\tErrMissingFromOrToFields  = errors.New(\"from and to fields are required\")\n\tErrInvalidAWSAuthConfig   = errors.New(\"either both or neither of access-key-id and secret-access-key must be specified\")\n)\n\ntype Config struct {\n\tAccessKeyID     string `yaml:\"access-key-id\"`\n\tSecretAccessKey string `yaml:\"secret-access-key\"`\n\tRegion          string `yaml:\"region\"`\n\n\tFrom string `yaml:\"from\"`\n\tTo   string `yaml:\"to\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.From) == 0 || len(cfg.To) == 0 {\n\t\treturn ErrMissingFromOrToFields\n\t}\n\tif !((len(cfg.AccessKeyID) == 0 && len(cfg.SecretAccessKey) == 0) || (len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0)) {\n\t\t// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,\n\t\t// otherwise if neither are specified, then we'll fall back on IAM authentication.\n\t\treturn ErrInvalidAWSAuthConfig\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.AccessKeyID) > 0 {\n\t\tcfg.AccessKeyID = override.AccessKeyID\n\t}\n\tif len(override.SecretAccessKey) > 0 {\n\t\tcfg.SecretAccessKey = override.SecretAccessKey\n\t}\n\tif len(override.Region) > 0 {\n\t\tcfg.Region = override.Region\n\t}\n\tif len(override.From) > 0 {\n\t\tcfg.From = override.From\n\t}\n\tif len(override.To) > 0 {\n\t\tcfg.To = override.To\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" || len(override.To) == 0 {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tctx := context.Background()\n\tsvc, err := provider.createClient(ctx, cfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsubject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)\n\temails := strings.Split(cfg.To, \",\")\n\tinput := &ses.SendEmailInput{\n\t\tDestination: &types.Destination{\n\t\t\tToAddresses: emails,\n\t\t},\n\t\tMessage: &types.Message{\n\t\t\tBody: &types.Body{\n\t\t\t\tText: &types.Content{\n\t\t\t\t\tCharset: aws.String(CharSet),\n\t\t\t\t\tData:    aws.String(body),\n\t\t\t\t},\n\t\t\t},\n\t\t\tSubject: &types.Content{\n\t\t\t\tCharset: aws.String(CharSet),\n\t\t\t\tData:    aws.String(subject),\n\t\t\t},\n\t\t},\n\t\tSource: aws.String(cfg.From),\n\t}\n\tif _, err = svc.SendEmail(ctx, input); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (provider *AlertProvider) createClient(ctx context.Context, cfg *Config) (*ses.Client, error) {\n\tvar opts []func(*config.LoadOptions) error\n\tif len(cfg.Region) > 0 {\n\t\topts = append(opts, config.WithRegion(cfg.Region))\n\t}\n\tif len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {\n\t\topts = append(opts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, \"\")))\n\t}\n\tawsConfig, err := config.LoadDefaultConfig(ctx, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ses.NewFromConfig(awsConfig), nil\n}\n\n// buildMessageSubjectAndBody builds the message subject and body\nfunc (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {\n\tvar subject, message string\n\tif resolved {\n\t\tsubject = fmt.Sprintf(\"[%s] Alert resolved\", ep.DisplayName())\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tsubject = fmt.Sprintf(\"[%s] Alert triggered\", ep.DisplayName())\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tvar formattedConditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tformattedConditionResults = \"\\n\\nCondition results:\\n\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \"✅\"\n\t\t\t} else {\n\t\t\t\tprefix = \"❌\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"%s %s\\n\", prefix, conditionResult.Condition)\n\t\t}\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \"\\n\\nAlert description: \" + alertDescription\n\t}\n\treturn subject, message + description + formattedConditionResults\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/awsses/awsses_test.go",
    "content": "package awsses\n\nimport (\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tinvalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: \"from@example.com\", To: \"to@example.com\", AccessKeyID: \"1\"}}\n\tif err := invalidProviderWithOneKey.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{From: \"from@example.com\", To: \"to@example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n\tvalidProviderWithKeys := AlertProvider{DefaultConfig: Config{From: \"from@example.com\", To: \"to@example.com\", AccessKeyID: \"1\", SecretAccessKey: \"1\"}}\n\tif err := validProviderWithKeys.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"to@example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider integration key shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tFrom: \"from@example.com\",\n\t\t\tTo:   \"to@example.com\",\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"to@example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName            string\n\t\tProvider        AlertProvider\n\t\tAlert           alert.Alert\n\t\tResolved        bool\n\t\tExpectedSubject string\n\t\tExpectedBody    string\n\t}{\n\t\t{\n\t\t\tName:            \"triggered\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        false,\n\t\t\tExpectedSubject: \"[endpoint-name] Alert triggered\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t\t{\n\t\t\tName:            \"resolved\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        true,\n\t\t\tExpectedSubject: \"[endpoint-name] Alert resolved\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tsubject, body := scenario.Provider.buildMessageSubjectAndBody(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif subject != scenario.ExpectedSubject {\n\t\t\t\tt.Errorf(\"expected subject to be %s, got %s\", scenario.ExpectedSubject, subject)\n\t\t\t}\n\t\t\tif body != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected body to be %s, got %s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_getConfigWithOverrides(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tFrom: \"from@example.com\",\n\t\t\t\t\tTo:   \"to@example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{From: \"from@example.com\", To: \"to@example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tFrom: \"from@example.com\",\n\t\t\t\t\tTo:   \"to@example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{From: \"from@example.com\", To: \"to@example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tFrom: \"from@example.com\",\n\t\t\t\t\tTo:   \"to@example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{To: \"groupto@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{From: \"from@example.com\", To: \"to@example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tFrom: \"from@example.com\",\n\t\t\t\t\tTo:   \"to@example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{To: \"groupto@example.com\", SecretAccessKey: \"wow\", AccessKeyID: \"noway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{From: \"from@example.com\", To: \"groupto@example.com\", SecretAccessKey: \"wow\", AccessKeyID: \"noway\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-but-alert-override-should-override-group-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tFrom: \"from@example.com\",\n\t\t\t\t\tTo:   \"to@example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{From: \"from@example.com\", To: \"groupto@example.com\", SecretAccessKey: \"sekrit\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"group\",\n\t\t\tInputAlert: alert.Alert{\n\t\t\t\tProviderOverride: map[string]any{\n\t\t\t\t\t\"to\":            \"alertto@example.com\",\n\t\t\t\t\t\"access-key-id\": 123,\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedOutput: Config{To: \"alertto@example.com\", From: \"from@example.com\", AccessKeyID: \"123\", SecretAccessKey: \"sekrit\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.From != scenario.ExpectedOutput.From {\n\t\t\t\tt.Errorf(\"expected From to be %s, got %s\", scenario.ExpectedOutput.From, got.From)\n\t\t\t}\n\t\t\tif got.To != scenario.ExpectedOutput.To {\n\t\t\t\tt.Errorf(\"expected To to be %s, got %s\", scenario.ExpectedOutput.To, got.To)\n\t\t\t}\n\t\t\tif got.AccessKeyID != scenario.ExpectedOutput.AccessKeyID {\n\t\t\t\tt.Errorf(\"expected AccessKeyID to be %s, got %s\", scenario.ExpectedOutput.AccessKeyID, got.AccessKeyID)\n\t\t\t}\n\t\t\tif got.SecretAccessKey != scenario.ExpectedOutput.SecretAccessKey {\n\t\t\t\tt.Errorf(\"expected SecretAccessKey to be %s, got %s\", scenario.ExpectedOutput.SecretAccessKey, got.SecretAccessKey)\n\t\t\t}\n\t\t\tif got.Region != scenario.ExpectedOutput.Region {\n\t\t\t\tt.Errorf(\"expected Region to be %s, got %s\", scenario.ExpectedOutput.Region, got.Region)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/clickup/clickup.go",
    "content": "package clickup\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrListIDNotSet           = errors.New(\"list-id not set\")\n\tErrTokenNotSet            = errors.New(\"token not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n\tErrInvalidPriority        = errors.New(\"priority must be one of: urgent, high, normal, low, none\")\n)\n\nvar priorityMap = map[string]int{\n\t\"urgent\": 1,\n\t\"high\":   2,\n\t\"normal\": 3,\n\t\"low\":    4,\n\t\"none\":   0,\n}\n\ntype Config struct {\n\tAPIURL          string   `yaml:\"api-url\"`\n\tListID          string   `yaml:\"list-id\"`\n\tToken           string   `yaml:\"token\"`\n\tAssignees       []string `yaml:\"assignees\"`\n\tStatus          string   `yaml:\"status\"`\n\tPriority        string   `yaml:\"priority\"`\n\tNotifyAll       *bool    `yaml:\"notify-all,omitempty\"`\n\tName            string   `yaml:\"name,omitempty\"`\n\tMarkdownContent string   `yaml:\"content,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif cfg.ListID == \"\" {\n\t\treturn ErrListIDNotSet\n\t}\n\tif cfg.Token == \"\" {\n\t\treturn ErrTokenNotSet\n\t}\n\tif cfg.Priority == \"\" {\n\t\tcfg.Priority = \"normal\"\n\t}\n\tif _, ok := priorityMap[cfg.Priority]; !ok {\n\t\treturn ErrInvalidPriority\n\t}\n\tif cfg.NotifyAll == nil {\n\t\tdefaultNotifyAll := true\n\t\tcfg.NotifyAll = &defaultNotifyAll\n\t}\n\tif cfg.APIURL == \"\" {\n\t\tcfg.APIURL = \"https://api.clickup.com/api/v2\"\n\t}\n\tif cfg.Name == \"\" {\n\t\tcfg.Name = \"Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]\"\n\t}\n\tif cfg.MarkdownContent == \"\" {\n\t\tcfg.MarkdownContent = \"Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]\"\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif override.APIURL != \"\" {\n\t\tcfg.APIURL = override.APIURL\n\t}\n\tif override.ListID != \"\" {\n\t\tcfg.ListID = override.ListID\n\t}\n\tif override.Token != \"\" {\n\t\tcfg.Token = override.Token\n\t}\n\tif override.Status != \"\" {\n\t\tcfg.Status = override.Status\n\t}\n\tif override.Priority != \"\" {\n\t\tcfg.Priority = override.Priority\n\t}\n\tif override.NotifyAll != nil {\n\t\tcfg.NotifyAll = override.NotifyAll\n\t}\n\tif len(override.Assignees) > 0 {\n\t\tcfg.Assignees = override.Assignees\n\t}\n\tif override.Name != \"\" {\n\t\tcfg.Name = override.Name\n\t}\n\tif override.MarkdownContent != \"\" {\n\t\tcfg.MarkdownContent = override.MarkdownContent\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using ClickUp\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default configuration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif resolved {\n\t\treturn provider.CloseTask(cfg, ep)\n\t}\n\t// Replace placeholders\n\tname := strings.ReplaceAll(cfg.Name, \"[ENDPOINT_GROUP]\", ep.Group)\n\tname = strings.ReplaceAll(name, \"[ENDPOINT_NAME]\", ep.Name)\n\tmarkdownContent := strings.ReplaceAll(cfg.MarkdownContent, \"[ENDPOINT_GROUP]\", ep.Group)\n\tmarkdownContent = strings.ReplaceAll(markdownContent, \"[ENDPOINT_NAME]\", ep.Name)\n\tmarkdownContent = strings.ReplaceAll(markdownContent, \"[ALERT_DESCRIPTION]\", alert.GetDescription())\n\tmarkdownContent = strings.ReplaceAll(markdownContent, \"[RESULT_ERRORS]\", strings.Join(result.Errors, \", \"))\n\tbody := map[string]interface{}{\n\t\t\"name\":             name,\n\t\t\"markdown_content\": markdownContent,\n\t\t\"assignees\":        cfg.Assignees,\n\t\t\"status\":           cfg.Status,\n\t\t\"notify_all\":       *cfg.NotifyAll,\n\t}\n\tif cfg.Priority != \"none\" {\n\t\tbody[\"priority\"] = priorityMap[cfg.Priority]\n\t}\n\treturn provider.CreateTask(cfg, body)\n}\n\nfunc (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error {\n\tjsonBody, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcreateURL := fmt.Sprintf(\"%s/list/%s/task\", cfg.APIURL, cfg.ListID)\n\treq, err := http.NewRequest(\"POST\", createURL, bytes.NewBuffer(jsonBody))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", cfg.Token)\n\thttpClient := client.GetHTTPClient(nil)\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"failed to create task, status: %d\", resp.StatusCode)\n\t}\n\treturn nil\n}\n\nfunc (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error {\n\tfetchURL := fmt.Sprintf(\"%s/list/%s/task?include_closed=false\", cfg.APIURL, cfg.ListID)\n\treq, err := http.NewRequest(\"GET\", fetchURL, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Authorization\", cfg.Token)\n\thttpClient := client.GetHTTPClient(nil)\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"failed to fetch tasks, status: %d\", resp.StatusCode)\n\t}\n\tvar fetchResponse struct {\n\t\tTasks []struct {\n\t\t\tID   string `json:\"id\"`\n\t\t\tName string `json:\"name\"`\n\t\t} `json:\"tasks\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil {\n\t\treturn err\n\t}\n\tvar matchingTaskIDs []string\n\tfor _, task := range fetchResponse.Tasks {\n\t\tif strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) {\n\t\t\tmatchingTaskIDs = append(matchingTaskIDs, task.ID)\n\t\t}\n\t}\n\tif len(matchingTaskIDs) == 0 {\n\t\treturn fmt.Errorf(\"no matching tasks found for %s:%s\", ep.Group, ep.Name)\n\t}\n\tfor _, taskID := range matchingTaskIDs {\n\t\tif err := provider.UpdateTaskStatus(cfg, taskID, \"closed\"); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to close task %s: %v\", taskID, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error {\n\tupdateURL := fmt.Sprintf(\"%s/task/%s\", cfg.APIURL, taskID)\n\tbody := map[string]interface{}{\"status\": status}\n\tjsonBody, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq, err := http.NewRequest(\"PUT\", updateURL, bytes.NewBuffer(jsonBody))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", cfg.Token)\n\thttpClient := client.GetHTTPClient(nil)\n\tresp, err := httpClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"failed to update task %s, status: %d\", taskID, resp.StatusCode)\n\t}\n\treturn nil\n}\n\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/clickup/clickup_test.go",
    "content": "package clickup\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: \"\", Token: \"test-token\"}}\n\tif err := invalidProviderNoListID.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid without list-id\")\n\t}\n\tinvalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"\"}}\n\tif err := invalidProviderNoToken.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid without token\")\n\t}\n\tinvalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\", Priority: \"invalid\"}}\n\tif err := invalidProviderBadPriority.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid with invalid priority\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n\tif validProvider.DefaultConfig.Priority != \"normal\" {\n\t\tt.Errorf(\"expected default priority to be 'normal', got '%s'\", validProvider.DefaultConfig.Priority)\n\t}\n\tvalidProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\", APIURL: \"https://api.clickup.com/api/v2\"}}\n\tif err := validProviderWithAPIURL.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n\tvalidProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\", Priority: \"urgent\"}}\n\tif err := validProviderWithPriority.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid with priority 'urgent'\")\n\t}\n\tvalidProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\", Priority: \"none\"}}\n\tif err := validProviderWithNone.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid with priority 'none'\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) {\n\tprovider := AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"}}\n\tif err := provider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n\tif provider.DefaultConfig.APIURL != \"https://api.clickup.com/api/v2\" {\n\t\tt.Errorf(\"expected APIURL to be set to default, got %s\", provider.DefaultConfig.APIURL)\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tEndpoint         endpoint.Endpoint\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"}},\n\t\t\tEndpoint: endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.Method == \"POST\" && r.URL.Path == \"/api/v2/list/test-list-id/task\" {\n\t\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"}},\n\t\t\tEndpoint: endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"}},\n\t\t\tEndpoint: endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.Method == \"GET\" {\n\t\t\t\t\t// Mock fetch tasks response\n\t\t\t\t\ttasksResponse := map[string]interface{}{\n\t\t\t\t\t\t\"tasks\": []map[string]interface{}{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"id\":   \"task-123\",\n\t\t\t\t\t\t\t\t\"name\": \"Health Check: endpoint-group:endpoint-name\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\tbody, _ := json.Marshal(tasksResponse)\n\t\t\t\t\treturn &http.Response{\n\t\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\t\tBody:       io.NopCloser(bytes.NewReader(body)),\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif r.Method == \"PUT\" {\n\t\t\t\t\t// Mock update task status response\n\t\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-no-matching-tasks\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"}},\n\t\t\tEndpoint: endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.Method == \"GET\" {\n\t\t\t\t\t// Mock fetch tasks response with no matching tasks\n\t\t\t\t\ttasksResponse := map[string]interface{}{\n\t\t\t\t\t\t\"tasks\": []map[string]interface{}{},\n\t\t\t\t\t}\n\t\t\t\t\tbody, _ := json.Marshal(tasksResponse)\n\t\t\t\t\treturn &http.Response{\n\t\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\t\tBody:       io.NopCloser(bytes.NewReader(body)),\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error-fetching-tasks\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"}},\n\t\t\tEndpoint: endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&scenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t\tErrors: []string{\"error1\", \"error2\"},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{ListID: \"test-list-id\", Token: \"test-token\", Priority: \"normal\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"},\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{ProviderOverride: map[string]any{\n\t\t\t\t\"list-id\": \"override-list-id\",\n\t\t\t\t\"token\":   \"override-token\",\n\t\t\t}},\n\t\t\tExpectedOutput: Config{ListID: \"override-list-id\", Token: \"override-token\", Priority: \"normal\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-partial-alert-override-should-merge\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\", Status: \"in progress\"},\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{ProviderOverride: map[string]any{\n\t\t\t\t\"status\": \"closed\",\n\t\t\t}},\n\t\t\tExpectedOutput: Config{ListID: \"test-list-id\", Token: \"test-token\", Status: \"closed\", Priority: \"normal\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-assignees-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"},\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{ProviderOverride: map[string]any{\n\t\t\t\t\"assignees\": []string{\"user1\", \"user2\"},\n\t\t\t}},\n\t\t\tExpectedOutput: Config{ListID: \"test-list-id\", Token: \"test-token\", Assignees: []string{\"user1\", \"user2\"}, Priority: \"normal\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-priority-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"},\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{ProviderOverride: map[string]any{\n\t\t\t\t\"priority\": \"urgent\",\n\t\t\t}},\n\t\t\tExpectedOutput: Config{ListID: \"test-list-id\", Token: \"test-token\", Priority: \"urgent\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-none-priority\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"},\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{ProviderOverride: map[string]any{\n\t\t\t\t\"priority\": \"none\",\n\t\t\t}},\n\t\t\tExpectedOutput: Config{ListID: \"test-list-id\", Token: \"test-token\", Priority: \"none\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ListID: \"test-list-id\", Token: \"test-token\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{Group: \"core\", Config: Config{ListID: \"core-list-id\", Priority: \"urgent\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"core\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{ListID: \"core-list-id\", Token: \"test-token\", Priority: \"urgent\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.ListID != scenario.ExpectedOutput.ListID {\n\t\t\t\tt.Errorf(\"expected ListID to be %s, got %s\", scenario.ExpectedOutput.ListID, got.ListID)\n\t\t\t}\n\t\t\tif got.Token != scenario.ExpectedOutput.Token {\n\t\t\t\tt.Errorf(\"expected Token to be %s, got %s\", scenario.ExpectedOutput.Token, got.Token)\n\t\t\t}\n\t\t\tif got.Status != scenario.ExpectedOutput.Status {\n\t\t\t\tt.Errorf(\"expected Status to be %s, got %s\", scenario.ExpectedOutput.Status, got.Status)\n\t\t\t}\n\t\t\tif got.Priority != scenario.ExpectedOutput.Priority {\n\t\t\t\tt.Errorf(\"expected Priority to be %s, got %s\", scenario.ExpectedOutput.Priority, got.Priority)\n\t\t\t}\n\t\t\tif len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {\n\t\t\t\tt.Errorf(\"expected Assignees length to be %d, got %d\", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/custom/custom.go",
    "content": "package custom\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrURLNotSet = errors.New(\"url not set\")\n)\n\ntype Config struct {\n\tURL          string                       `yaml:\"url\"`\n\tMethod       string                       `yaml:\"method,omitempty\"`\n\tBody         string                       `yaml:\"body,omitempty\"`\n\tHeaders      map[string]string            `yaml:\"headers,omitempty\"`\n\tPlaceholders map[string]map[string]string `yaml:\"placeholders,omitempty\"`\n\n\t// ClientConfig is the configuration of the client used to communicate with the provider's target\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.URL) == 0 {\n\t\treturn ErrURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif override.ClientConfig != nil {\n\t\tcfg.ClientConfig = override.ClientConfig\n\t}\n\tif len(override.URL) > 0 {\n\t\tcfg.URL = override.URL\n\t}\n\tif len(override.Method) > 0 {\n\t\tcfg.Method = override.Method\n\t}\n\tif len(override.Body) > 0 {\n\t\tcfg.Body = override.Body\n\t}\n\tif len(override.Headers) > 0 {\n\t\tcfg.Headers = override.Headers\n\t}\n\tif len(override.Placeholders) > 0 {\n\t\tcfg.Placeholders = override.Placeholders\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request\n// Technically, all alert providers should be reachable using the custom alert provider\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\treturn provider.DefaultConfig.Validate()\n}\n\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)\n\tresponse, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\nfunc (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {\n\tbody, url, method := cfg.Body, cfg.URL, cfg.Method\n\tbody = strings.ReplaceAll(body, \"[ALERT_DESCRIPTION]\", alert.GetDescription())\n\turl = strings.ReplaceAll(url, \"[ALERT_DESCRIPTION]\", alert.GetDescription())\n\tbody = strings.ReplaceAll(body, \"[ENDPOINT_NAME]\", ep.Name)\n\turl = strings.ReplaceAll(url, \"[ENDPOINT_NAME]\", ep.Name)\n\tbody = strings.ReplaceAll(body, \"[ENDPOINT_GROUP]\", ep.Group)\n\turl = strings.ReplaceAll(url, \"[ENDPOINT_GROUP]\", ep.Group)\n\tbody = strings.ReplaceAll(body, \"[ENDPOINT_URL]\", ep.URL)\n\turl = strings.ReplaceAll(url, \"[ENDPOINT_URL]\", ep.URL)\n\tresultErrors := strings.ReplaceAll(strings.Join(result.Errors, \",\"), \"\\\"\", \"\\\\\\\"\")\n\tbody = strings.ReplaceAll(body, \"[RESULT_ERRORS]\", resultErrors)\n\turl = strings.ReplaceAll(url, \"[RESULT_ERRORS]\", resultErrors)\n\n\tif len(result.ConditionResults) > 0 && strings.Contains(body, \"[RESULT_CONDITIONS]\") {\n\t\tvar formattedConditionResults string\n\t\tfor index, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \"✅\"\n\t\t\t} else {\n\t\t\t\tprefix = \"❌\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"%s - `%s`\", prefix, conditionResult.Condition)\n\t\t\tif index < len(result.ConditionResults)-1 {\n\t\t\t\tformattedConditionResults += \", \"\n\t\t\t}\n\t\t}\n\t\tbody = strings.ReplaceAll(body, \"[RESULT_CONDITIONS]\", formattedConditionResults)\n\t\turl = strings.ReplaceAll(url, \"[RESULT_CONDITIONS]\", formattedConditionResults)\n\t}\n\n\tif resolved {\n\t\tbody = strings.ReplaceAll(body, \"[ALERT_TRIGGERED_OR_RESOLVED]\", provider.GetAlertStatePlaceholderValue(cfg, true))\n\t\turl = strings.ReplaceAll(url, \"[ALERT_TRIGGERED_OR_RESOLVED]\", provider.GetAlertStatePlaceholderValue(cfg, true))\n\t} else {\n\t\tbody = strings.ReplaceAll(body, \"[ALERT_TRIGGERED_OR_RESOLVED]\", provider.GetAlertStatePlaceholderValue(cfg, false))\n\t\turl = strings.ReplaceAll(url, \"[ALERT_TRIGGERED_OR_RESOLVED]\", provider.GetAlertStatePlaceholderValue(cfg, false))\n\t}\n\tif len(method) == 0 {\n\t\tmethod = http.MethodGet\n\t}\n\tbodyBuffer := bytes.NewBuffer([]byte(body))\n\trequest, _ := http.NewRequest(method, url, bodyBuffer)\n\tfor k, v := range cfg.Headers {\n\t\trequest.Header.Set(k, v)\n\t}\n\treturn request\n}\n\n// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured\nfunc (provider *AlertProvider) GetAlertStatePlaceholderValue(cfg *Config, resolved bool) string {\n\tstatus := \"TRIGGERED\"\n\tif resolved {\n\t\tstatus = \"RESOLVED\"\n\t}\n\tif _, ok := cfg.Placeholders[\"ALERT_TRIGGERED_OR_RESOLVED\"]; ok {\n\t\tif val, ok := cfg.Placeholders[\"ALERT_TRIGGERED_OR_RESOLVED\"][status]; ok {\n\t\t\treturn val\n\t\t}\n\t}\n\treturn status\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/custom/custom_test.go",
    "content": "package custom\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tt.Run(\"invalid-provider\", func(t *testing.T) {\n\t\tinvalidProvider := AlertProvider{DefaultConfig: Config{URL: \"\"}}\n\t\tif err := invalidProvider.Validate(); err == nil {\n\t\t\tt.Error(\"provider shouldn't have been valid\")\n\t\t}\n\t})\n\tt.Run(\"valid-provider\", func(t *testing.T) {\n\t\tvalidProvider := AlertProvider{DefaultConfig: Config{URL: \"https://example.com\"}}\n\t\tif err := validProvider.Validate(); err != nil {\n\t\t\tt.Error(\"provider should've been valid\")\n\t\t}\n\t})\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{URL: \"https://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{URL: \"https://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{URL: \"https://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{URL: \"https://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildHTTPRequest(t *testing.T) {\n\talertProvider := &AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tURL:  \"https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]\",\n\t\t\tBody: \"[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]\",\n\t\t},\n\t}\n\talertDescription := \"alert-description\"\n\tscenarios := []struct {\n\t\tAlertProvider *AlertProvider\n\t\tResolved      bool\n\t\tExpectedURL   string\n\t\tExpectedBody  string\n\t}{\n\t\t{\n\t\t\tAlertProvider: alertProvider,\n\t\t\tResolved:      true,\n\t\t\tExpectedURL:   \"https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com\",\n\t\t\tExpectedBody:  \"endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED\",\n\t\t},\n\t\t{\n\t\t\tAlertProvider: alertProvider,\n\t\t\tResolved:      false,\n\t\t\tExpectedURL:   \"https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com\",\n\t\t\tExpectedBody:  \"endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(fmt.Sprintf(\"resolved-%v-with-default-placeholders\", scenario.Resolved), func(t *testing.T) {\n\t\t\trequest := alertProvider.buildHTTPRequest(\n\t\t\t\t&alertProvider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\", URL: \"https://example.com\"},\n\t\t\t\t&alert.Alert{Description: &alertDescription},\n\t\t\t\t&endpoint.Result{Errors: []string{}},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif request.URL.String() != scenario.ExpectedURL {\n\t\t\t\tt.Error(\"expected URL to be\", scenario.ExpectedURL, \"got\", request.URL.String())\n\t\t\t}\n\t\t\tbody, _ := io.ReadAll(request.Body)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Error(\"expected body to be\", scenario.ExpectedBody, \"got\", string(body))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {\n\talertProvider := &AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tURL:  \"https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]\",\n\t\t\tBody: \"[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]\",\n\t\t},\n\t}\n\talertDescription := \"alert-description\"\n\tscenarios := []struct {\n\t\tAlertProvider *AlertProvider\n\t\tResolved      bool\n\t\tExpectedURL   string\n\t\tExpectedBody  string\n\t\tErrors        []string\n\t}{\n\t\t{\n\t\t\tAlertProvider: alertProvider,\n\t\t\tResolved:      true,\n\t\t\tExpectedURL:   \"https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=\",\n\t\t\tExpectedBody:  \"endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,\",\n\t\t},\n\t\t{\n\t\t\tAlertProvider: alertProvider,\n\t\t\tResolved:      false,\n\t\t\tExpectedURL:   \"https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2\",\n\t\t\tExpectedBody:  \"endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2\",\n\t\t\tErrors:        []string{\"error1\", \"error2\"},\n\t\t},\n\t\t{\n\t\t\tAlertProvider: alertProvider,\n\t\t\tResolved:      false,\n\t\t\tExpectedURL:   \"https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=test \\\\\\\"error with quotes\\\\\\\"\",\n\t\t\tExpectedBody:  \"endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,test \\\\\\\"error with quotes\\\\\\\"\",\n\t\t\tErrors:        []string{\"test \\\"error with quotes\\\"\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(fmt.Sprintf(\"resolved-%v-with-default-placeholders-and-result-errors\", scenario.Resolved), func(t *testing.T) {\n\t\t\trequest := alertProvider.buildHTTPRequest(\n\t\t\t\t&alertProvider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\", URL: \"https://example.com\"},\n\t\t\t\t&alert.Alert{Description: &alertDescription},\n\t\t\t\t&endpoint.Result{Errors: scenario.Errors},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif request.URL.String() != scenario.ExpectedURL {\n\t\t\t\tt.Error(\"expected URL to be\", scenario.ExpectedURL, \"got\", request.URL.String())\n\t\t\t}\n\t\t\tbody, _ := io.ReadAll(request.Body)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Error(\"expected body to be\", scenario.ExpectedBody, \"got\", string(body))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {\n\talertProvider := &AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tURL:     \"https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]\",\n\t\t\tBody:    \"[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]\",\n\t\t\tHeaders: nil,\n\t\t\tPlaceholders: map[string]map[string]string{\n\t\t\t\t\"ALERT_TRIGGERED_OR_RESOLVED\": {\n\t\t\t\t\t\"RESOLVED\":  \"fixed\",\n\t\t\t\t\t\"TRIGGERED\": \"boom\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\talertDescription := \"alert-description\"\n\tscenarios := []struct {\n\t\tAlertProvider *AlertProvider\n\t\tResolved      bool\n\t\tExpectedURL   string\n\t\tExpectedBody  string\n\t}{\n\t\t{\n\t\t\tAlertProvider: alertProvider,\n\t\t\tResolved:      true,\n\t\t\tExpectedURL:   \"https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description\",\n\t\t\tExpectedBody:  \"endpoint-name,endpoint-group,alert-description,fixed\",\n\t\t},\n\t\t{\n\t\t\tAlertProvider: alertProvider,\n\t\t\tResolved:      false,\n\t\t\tExpectedURL:   \"https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description\",\n\t\t\tExpectedBody:  \"endpoint-name,endpoint-group,alert-description,boom\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(fmt.Sprintf(\"resolved-%v-with-custom-placeholders\", scenario.Resolved), func(t *testing.T) {\n\t\t\trequest := alertProvider.buildHTTPRequest(\n\t\t\t\t&alertProvider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\t\t&alert.Alert{Description: &alertDescription},\n\t\t\t\t&endpoint.Result{},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif request.URL.String() != scenario.ExpectedURL {\n\t\t\t\tt.Error(\"expected URL to be\", scenario.ExpectedURL, \"got\", request.URL.String())\n\t\t\t}\n\t\t\tbody, _ := io.ReadAll(request.Body)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Error(\"expected body to be\", scenario.ExpectedBody, \"got\", string(body))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildHTTPRequestWithCustomPlaceholderAndResultConditions(t *testing.T) {\n\talertProvider := &AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tURL:     \"https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]\",\n\t\t\tBody:    \"[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_CONDITIONS]\",\n\t\t\tHeaders: nil,\n\t\t\tPlaceholders: map[string]map[string]string{\n\t\t\t\t\"ALERT_TRIGGERED_OR_RESOLVED\": {\n\t\t\t\t\t\"RESOLVED\":  \"fixed\",\n\t\t\t\t\t\"TRIGGERED\": \"boom\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\talertDescription := \"alert-description\"\n\tscenarios := []struct {\n\t\tAlertProvider *AlertProvider\n\t\tResolved      bool\n\t\tExpectedURL   string\n\t\tExpectedBody  string\n\t\tNoConditions  bool\n\t}{\n\t\t{\n\t\t\tAlertProvider: alertProvider,\n\t\t\tResolved:      true,\n\t\t\tExpectedURL:   \"https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description\",\n\t\t\tExpectedBody:  \"endpoint-name,endpoint-group,alert-description,fixed,✅ - `[CONNECTED] == true`, ✅ - `[STATUS] == 200`\",\n\t\t},\n\t\t{\n\t\t\tAlertProvider: alertProvider,\n\t\t\tResolved:      false,\n\t\t\tExpectedURL:   \"https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description\",\n\t\t\tExpectedBody:  \"endpoint-name,endpoint-group,alert-description,boom,❌ - `[CONNECTED] == true`, ❌ - `[STATUS] == 200`\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(fmt.Sprintf(\"resolved-%v-with-custom-placeholders\", scenario.Resolved), func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif !scenario.NoConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t}\n\t\t\t}\n\n\t\t\trequest := alertProvider.buildHTTPRequest(\n\t\t\t\t&alertProvider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\t\t&alert.Alert{Description: &alertDescription},\n\t\t\t\t&endpoint.Result{ConditionResults: conditionResults},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif request.URL.String() != scenario.ExpectedURL {\n\t\t\t\tt.Error(\"expected URL to be\", scenario.ExpectedURL, \"got\", request.URL.String())\n\t\t\t}\n\t\t\tbody, _ := io.ReadAll(request.Body)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Error(\"expected body to be\", scenario.ExpectedBody, \"got\", string(body))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {\n\talertProvider := &AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tURL:  \"https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]\",\n\t\t\tBody: \"[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]\",\n\t\t},\n\t}\n\tif alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != \"RESOLVED\" {\n\t\tt.Error(\"expected RESOLVED, got\", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))\n\t}\n\tif alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != \"TRIGGERED\" {\n\t\tt.Error(\"expected TRIGGERED, got\", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"http://example.com\", Body: \"default-body\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"http://example.com\", Body: \"default-body\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{URL: \"http://group-example.com\", Headers: map[string]string{\"Cache\": \"true\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"http://example.com\", Headers: map[string]string{\"Cache\": \"true\"}},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"http://example.com\", Body: \"default-body\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{URL: \"http://group-example.com\", Body: \"group-body\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"http://group-example.com\", Body: \"group-body\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{URL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"url\": \"http://alert-example.com\", \"body\": \"alert-body\"}},\n\t\t\tExpectedOutput: Config{URL: \"http://alert-example.com\", Body: \"alert-body\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-partial-overrides\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{Method: \"POST\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"body\": \"alert-body\"}},\n\t\t\tExpectedOutput: Config{URL: \"http://example.com\", Body: \"alert-body\", Method: \"POST\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.URL != scenario.ExpectedOutput.URL {\n\t\t\t\tt.Errorf(\"expected webhook URL to be %s, got %s\", scenario.ExpectedOutput.URL, got.URL)\n\t\t\t}\n\t\t\tif got.Body != scenario.ExpectedOutput.Body {\n\t\t\t\tt.Errorf(\"expected body to be %s, got %s\", scenario.ExpectedOutput.Body, got.Body)\n\t\t\t}\n\t\t\tif got.Headers != nil {\n\t\t\t\tfor key, value := range scenario.ExpectedOutput.Headers {\n\t\t\t\t\tif got.Headers[key] != value {\n\t\t\t\t\t\tt.Errorf(\"expected header %s to be %s, got %s\", key, value, got.Headers[key])\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/datadog/datadog.go",
    "content": "package datadog\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrAPIKeyNotSet           = errors.New(\"api-key not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tAPIKey string   `yaml:\"api-key\"`        // Datadog API key\n\tSite   string   `yaml:\"site,omitempty\"` // Datadog site (e.g., datadoghq.com, datadoghq.eu)\n\tTags   []string `yaml:\"tags,omitempty\"` // Additional tags to include\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.APIKey) == 0 {\n\t\treturn ErrAPIKeyNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.APIKey) > 0 {\n\t\tcfg.APIKey = override.APIKey\n\t}\n\tif len(override.Site) > 0 {\n\t\tcfg.Site = override.Site\n\t}\n\tif len(override.Tags) > 0 {\n\t\tcfg.Tags = override.Tags\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Datadog\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsite := cfg.Site\n\tif site == \"\" {\n\t\tsite = \"datadoghq.com\"\n\t}\n\tbody, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(body)\n\turl := fmt.Sprintf(\"https://api.%s/api/v1/events\", site)\n\trequest, err := http.NewRequest(http.MethodPost, url, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"DD-API-KEY\", cfg.APIKey)\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to datadog alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tTitle        string   `json:\"title\"`\n\tText         string   `json:\"text\"`\n\tPriority     string   `json:\"priority\"`\n\tTags         []string `json:\"tags\"`\n\tAlertType    string   `json:\"alert_type\"`\n\tSourceType   string   `json:\"source_type_name\"`\n\tDateHappened int64    `json:\"date_happened,omitempty\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {\n\tvar title, text, priority, alertType string\n\tif resolved {\n\t\ttitle = fmt.Sprintf(\"Resolved: %s\", ep.DisplayName())\n\t\ttext = fmt.Sprintf(\"Alert for %s has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tpriority = \"normal\"\n\t\talertType = \"success\"\n\t} else {\n\t\ttitle = fmt.Sprintf(\"Alert: %s\", ep.DisplayName())\n\t\ttext = fmt.Sprintf(\"Alert for %s has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t\tpriority = \"normal\"\n\t\talertType = \"error\"\n\t}\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\ttext += fmt.Sprintf(\"\\n\\nDescription: %s\", alertDescription)\n\t}\n\tif len(result.ConditionResults) > 0 {\n\t\ttext += \"\\n\\nCondition Results:\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar status string\n\t\t\tif conditionResult.Success {\n\t\t\t\tstatus = \"✅\"\n\t\t\t} else {\n\t\t\t\tstatus = \"❌\"\n\t\t\t}\n\t\t\ttext += fmt.Sprintf(\"\\n%s %s\", status, conditionResult.Condition)\n\t\t}\n\t}\n\ttags := []string{\n\t\t\"source:gatus\",\n\t\tfmt.Sprintf(\"endpoint:%s\", ep.Name),\n\t\tfmt.Sprintf(\"status:%s\", alertType),\n\t}\n\tif ep.Group != \"\" {\n\t\ttags = append(tags, fmt.Sprintf(\"group:%s\", ep.Group))\n\t}\n\t// Append custom tags\n\tif len(cfg.Tags) > 0 {\n\t\ttags = append(tags, cfg.Tags...)\n\t}\n\tbody := Body{\n\t\tTitle:        title,\n\t\tText:         text,\n\t\tPriority:     priority,\n\t\tTags:         tags,\n\t\tAlertType:    alertType,\n\t\tSourceType:   \"gatus\",\n\t\tDateHappened: time.Now().Unix(),\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/datadog/datadog_test.go",
    "content": "package datadog\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid-us1\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{APIKey: \"dd-api-key-123\", Site: \"datadoghq.com\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid-eu\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{APIKey: \"dd-api-key-123\", Site: \"datadoghq.eu\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid-with-tags\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{APIKey: \"dd-api-key-123\", Site: \"datadoghq.com\", Tags: []string{\"env:prod\", \"service:gatus\"}}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-api-key\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{Site: \"datadoghq.com\"}},\n\t\t\texpected: ErrAPIKeyNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{APIKey: \"dd-api-key-123\", Site: \"datadoghq.com\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.Host != \"api.datadoghq.com\" {\n\t\t\t\t\tt.Errorf(\"expected host api.datadoghq.com, got %s\", r.Host)\n\t\t\t\t}\n\t\t\t\tif r.URL.Path != \"/api/v1/events\" {\n\t\t\t\t\tt.Errorf(\"expected path /api/v1/events, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tif r.Header.Get(\"DD-API-KEY\") != \"dd-api-key-123\" {\n\t\t\t\t\tt.Errorf(\"expected DD-API-KEY header to be 'dd-api-key-123', got %s\", r.Header.Get(\"DD-API-KEY\"))\n\t\t\t\t}\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"title\"] == nil {\n\t\t\t\t\tt.Error(\"expected 'title' field in request body\")\n\t\t\t\t}\n\t\t\t\ttitle := body[\"title\"].(string)\n\t\t\t\tif !strings.Contains(title, \"Alert\") {\n\t\t\t\t\tt.Errorf(\"expected title to contain 'Alert', got %s\", title)\n\t\t\t\t}\n\t\t\t\tif body[\"alert_type\"] != \"error\" {\n\t\t\t\t\tt.Errorf(\"expected alert_type to be 'error', got %v\", body[\"alert_type\"])\n\t\t\t\t}\n\t\t\t\tif body[\"priority\"] != \"normal\" {\n\t\t\t\t\tt.Errorf(\"expected priority to be 'normal', got %v\", body[\"priority\"])\n\t\t\t\t}\n\t\t\t\ttext := body[\"text\"].(string)\n\t\t\t\tif !strings.Contains(text, \"failed 3 time(s)\") {\n\t\t\t\t\tt.Errorf(\"expected text to contain failure count, got %s\", text)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"triggered-with-tags\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{APIKey: \"dd-api-key-123\", Site: \"datadoghq.com\", Tags: []string{\"env:prod\", \"service:gatus\"}}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\ttags := body[\"tags\"].([]interface{})\n\t\t\t\t// Datadog adds 3 base tags (source, endpoint, status) + custom tags\n\t\t\t\tif len(tags) < 5 {\n\t\t\t\t\tt.Errorf(\"expected at least 5 tags (3 base + 2 custom), got %d\", len(tags))\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{APIKey: \"dd-api-key-123\", Site: \"datadoghq.eu\"}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.Host != \"api.datadoghq.eu\" {\n\t\t\t\t\tt.Errorf(\"expected host api.datadoghq.eu, got %s\", r.Host)\n\t\t\t\t}\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\ttitle := body[\"title\"].(string)\n\t\t\t\tif !strings.Contains(title, \"Resolved\") {\n\t\t\t\t\tt.Errorf(\"expected title to contain 'Resolved', got %s\", title)\n\t\t\t\t}\n\t\t\t\tif body[\"alert_type\"] != \"success\" {\n\t\t\t\t\tt.Errorf(\"expected alert_type to be 'success', got %v\", body[\"alert_type\"])\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{APIKey: \"dd-api-key-123\", Site: \"datadoghq.com\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/discord/discord.go",
    "content": "package discord\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL     string `yaml:\"webhook-url\"`\n\tTitle          string `yaml:\"title,omitempty\"`           // Title of the message that will be sent\n\tMessageContent string `yaml:\"message-content,omitempty\"` // Message content for pinging users or groups (e.g. \"<@123456789>\" or \"<@&987654321>\")\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n\tif len(override.Title) > 0 {\n\t\tcfg.Title = override.Title\n\t}\n\tif len(override.MessageContent) > 0 {\n\t\tcfg.MessageContent = override.MessageContent\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Discord\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" || len(override.WebhookURL) == 0 {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tContent string  `json:\"content\"`\n\tEmbeds  []Embed `json:\"embeds\"`\n}\n\ntype Embed struct {\n\tTitle       string  `json:\"title\"`\n\tDescription string  `json:\"description\"`\n\tColor       int     `json:\"color\"`\n\tFields      []Field `json:\"fields,omitempty\"`\n}\n\ntype Field struct {\n\tName   string `json:\"name\"`\n\tValue  string `json:\"value\"`\n\tInline bool   `json:\"inline\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message string\n\tvar colorCode int\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for **%s** has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tcolorCode = 3066993\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for **%s** has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t\tcolorCode = 15158332\n\t}\n\tvar formattedConditionResults string\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \":white_check_mark:\"\n\t\t} else {\n\t\t\tprefix = \":x:\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"%s - `%s`\\n\", prefix, conditionResult.Condition)\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \":\\n> \" + alertDescription\n\t}\n\ttitle := \":helmet_with_white_cross: Gatus\"\n\tif cfg.Title != \"\" {\n\t\ttitle = cfg.Title\n\t}\n\tbody := Body{\n\t\tContent: cfg.MessageContent,\n\t\tEmbeds: []Embed{\n\t\t\t{\n\t\t\t\tTitle:       title,\n\t\t\t\tDescription: message + description,\n\t\t\t\tColor:       colorCode,\n\t\t\t},\n\t\t},\n\t}\n\tif len(formattedConditionResults) > 0 {\n\t\tbody.Embeds[0].Fields = append(body.Embeds[0].Fields, Field{\n\t\t\tName:   \"Condition results\",\n\t\t\tValue:  formattedConditionResults,\n\t\t\tInline: false,\n\t\t})\n\t}\n\tbodyAsJSON, _ := json.Marshal(body)\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/discord/discord_test.go",
    "content": "package discord\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider integration key shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tWebhookURL: \"http://example.com\",\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\ttitle := \"provider-title\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-with-modified-title\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\", Title: title}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-with-webhook-override\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{\"webhook-url\": \"http://example01.com\"}},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-with-message-content\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\", MessageContent: \"<@123456789>\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\ttitle := \"provider-title\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tNoConditions bool\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-modified-title\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{Title: title}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-no-conditions\",\n\t\t\tNoConditions: true,\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{Title: title}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-message-content-user-mention\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{MessageContent: \"<@123456789>\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-message-content-role-mention\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{MessageContent: \"<@&987654321>\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-with-message-content\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{MessageContent: \"<@123456789>\"}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif !scenario.NoConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t{Condition: \"[BODY] != \\\"\\\"\", Success: scenario.Resolved},\n\t\t\t\t}\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: conditionResults,\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://group-example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"webhook-url\": \"http://alert-example.com\"}},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://alert-example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-message-content-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\", MessageContent: \"<@123456789>\"},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\", MessageContent: \"<@123456789>\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-message-content-group-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\", MessageContent: \"<@123456789>\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\", MessageContent: \"<@&987654321>\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://group-example.com\", MessageContent: \"<@&987654321>\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-message-content-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\", MessageContent: \"<@123456789>\"},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"message-content\": \"<@999999999>\"}},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\", MessageContent: \"<@999999999>\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.WebhookURL != scenario.ExpectedOutput.WebhookURL {\n\t\t\t\tt.Errorf(\"expected webhook URL to be %s, got %s\", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)\n\t\t\t}\n\t\t\tif got.MessageContent != scenario.ExpectedOutput.MessageContent {\n\t\t\t\tt.Errorf(\"expected message content to be %s, got %s\", scenario.ExpectedOutput.MessageContent, got.MessageContent)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/email/email.go",
    "content": "package email\n\nimport (\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\tgomail \"gopkg.in/mail.v2\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n\tErrMissingFromOrToFields  = errors.New(\"from and to fields are required\")\n\tErrInvalidPort            = errors.New(\"port must be between 1 and 65535 inclusively\")\n\tErrMissingHost            = errors.New(\"host is required\")\n)\n\ntype Config struct {\n\tFrom     string `yaml:\"from\"`\n\tUsername string `yaml:\"username\"`\n\tPassword string `yaml:\"password\"`\n\tHost     string `yaml:\"host\"`\n\tPort     int    `yaml:\"port\"`\n\tTo       string `yaml:\"to\"`\n\n\t// ClientConfig is the configuration of the client used to communicate with the provider's target\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.From) == 0 || len(cfg.To) == 0 {\n\t\treturn ErrMissingFromOrToFields\n\t}\n\tif cfg.Port < 1 || cfg.Port > math.MaxUint16 {\n\t\treturn ErrInvalidPort\n\t}\n\tif len(cfg.Host) == 0 {\n\t\treturn ErrMissingHost\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif override.ClientConfig != nil {\n\t\tcfg.ClientConfig = override.ClientConfig\n\t}\n\tif len(override.From) > 0 {\n\t\tcfg.From = override.From\n\t}\n\tif len(override.Username) > 0 {\n\t\tcfg.Username = override.Username\n\t}\n\tif len(override.Password) > 0 {\n\t\tcfg.Password = override.Password\n\t}\n\tif len(override.Host) > 0 {\n\t\tcfg.Host = override.Host\n\t}\n\tif override.Port > 0 {\n\t\tcfg.Port = override.Port\n\t}\n\tif len(override.To) > 0 {\n\t\tcfg.To = override.To\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using SMTP\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" || len(override.To) == 0 {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar username string\n\tif len(cfg.Username) > 0 {\n\t\tusername = cfg.Username\n\t} else {\n\t\tusername = cfg.From\n\t}\n\tsubject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)\n\tm := gomail.NewMessage()\n\tm.SetHeader(\"From\", cfg.From)\n\tm.SetHeader(\"To\", strings.Split(cfg.To, \",\")...)\n\tm.SetHeader(\"Subject\", subject)\n\tm.SetBody(\"text/plain\", body)\n\tvar d *gomail.Dialer\n\tif len(cfg.Password) == 0 {\n\t\t// Get the domain in the From address\n\t\tlocalName := \"localhost\"\n\t\tfromParts := strings.Split(cfg.From, `@`)\n\t\tif len(fromParts) == 2 {\n\t\t\tlocalName = fromParts[1]\n\t\t}\n\t\t// Create a dialer with no authentication\n\t\td = &gomail.Dialer{Host: cfg.Host, Port: cfg.Port, LocalName: localName}\n\t} else {\n\t\t// Create an authenticated dialer\n\t\td = gomail.NewDialer(cfg.Host, cfg.Port, username, cfg.Password)\n\t}\n\tif cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {\n\t\td.TLSConfig = &tls.Config{InsecureSkipVerify: true}\n\t}\n\treturn d.DialAndSend(m)\n}\n\n// buildMessageSubjectAndBody builds the message subject and body\nfunc (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {\n\tvar subject, message string\n\tif resolved {\n\t\tsubject = fmt.Sprintf(\"[%s] Alert resolved\", ep.DisplayName())\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tsubject = fmt.Sprintf(\"[%s] Alert triggered\", ep.DisplayName())\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tvar formattedConditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tformattedConditionResults = \"\\n\\nCondition results:\\n\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \"✅\"\n\t\t\t} else {\n\t\t\t\tprefix = \"❌\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"%s %s\\n\", prefix, conditionResult.Condition)\n\t\t}\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \"\\n\\nAlert description: \" + alertDescription\n\t}\n\tvar extraLabels string\n\tif len(ep.ExtraLabels) > 0 {\n\t\textraLabels = \"\\n\\nExtra labels:\\n\"\n\t\tfor key, value := range ep.ExtraLabels {\n\t\t\textraLabels += fmt.Sprintf(\"  %s: %s\\n\", key, value)\n\t\t}\n\t}\n\treturn subject, message + description + extraLabels + formattedConditionResults\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/email/email_test.go",
    "content": "package email\n\nimport (\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{From: \"from@example.com\", Password: \"password\", Host: \"smtp.gmail.com\", Port: 587, To: \"to@example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {\n\tvalidProvider := AlertProvider{DefaultConfig: Config{From: \"from@example.com\", Host: \"smtp-relay.gmail.com\", Port: 587, To: \"to@example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"to@example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider integration key shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tFrom:     \"from@example.com\",\n\t\t\tPassword: \"password\",\n\t\t\tHost:     \"smtp.gmail.com\",\n\t\t\tPort:     587,\n\t\t\tTo:       \"to@example.com\",\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"to@example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName            string\n\t\tProvider        AlertProvider\n\t\tAlert           alert.Alert\n\t\tResolved        bool\n\t\tEndpoint        *endpoint.Endpoint\n\t\tExpectedSubject string\n\t\tExpectedBody    string\n\t}{\n\t\t{\n\t\t\tName:            \"triggered\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        false,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert triggered\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t\t{\n\t\t\tName:            \"resolved\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        true,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert resolved\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t\t{\n\t\t\tName:            \"triggered-with-single-extra-label\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        false,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\", ExtraLabels: map[string]string{\"environment\": \"production\"}},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert triggered\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t\t{\n\t\t\tName:            \"resolved-with-single-extra-label\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        true,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\", ExtraLabels: map[string]string{\"service\": \"api\"}},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert resolved\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t\t{\n\t\t\tName:            \"triggered-with-no-extra-labels\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        false,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\", ExtraLabels: map[string]string{}},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert triggered\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tsubject, body := scenario.Provider.buildMessageSubjectAndBody(\n\t\t\t\tscenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif subject != scenario.ExpectedSubject {\n\t\t\t\tt.Errorf(\"expected subject to be %s, got %s\", scenario.ExpectedSubject, subject)\n\t\t\t}\n\t\t\tif body != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected body to be %s, got %s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{From: \"from@example.com\", To: \"to@example.com\", Host: \"smtp.gmail.com\", Port: 587, Password: \"password\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{From: \"from@example.com\", To: \"to@example.com\", Host: \"smtp.gmail.com\", Port: 587, Password: \"password\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{From: \"from@example.com\", To: \"to@example.com\", Host: \"smtp.gmail.com\", Port: 587, Password: \"password\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{From: \"from@example.com\", To: \"to@example.com\", Host: \"smtp.gmail.com\", Port: 587, Password: \"password\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{From: \"from@example.com\", To: \"to@example.com\", Host: \"smtp.gmail.com\", Port: 587, Password: \"password\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{To: \"to01@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{From: \"from@example.com\", To: \"to@example.com\", Host: \"smtp.gmail.com\", Port: 587, Password: \"password\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{From: \"from@example.com\", To: \"to@example.com\", Host: \"smtp.gmail.com\", Port: 587, Password: \"password\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{To: \"group-to@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{From: \"from@example.com\", To: \"group-to@example.com\", Host: \"smtp.gmail.com\", Port: 587, Password: \"password\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{From: \"from@example.com\", To: \"to@example.com\", Host: \"smtp.gmail.com\", Port: 587, Password: \"password\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{To: \"group-to@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"to\": \"alert-to@example.com\", \"host\": \"smtp.example.com\", \"port\": 588, \"password\": \"hunter2\"}},\n\t\t\tExpectedOutput: Config{From: \"from@example.com\", To: \"alert-to@example.com\", Host: \"smtp.example.com\", Port: 588, Password: \"hunter2\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.From != scenario.ExpectedOutput.From {\n\t\t\t\tt.Errorf(\"expected from to be %s, got %s\", scenario.ExpectedOutput.From, got.From)\n\t\t\t}\n\t\t\tif got.To != scenario.ExpectedOutput.To {\n\t\t\t\tt.Errorf(\"expected to be %s, got %s\", scenario.ExpectedOutput.To, got.To)\n\t\t\t}\n\t\t\tif got.Host != scenario.ExpectedOutput.Host {\n\t\t\t\tt.Errorf(\"expected host to be %s, got %s\", scenario.ExpectedOutput.Host, got.Host)\n\t\t\t}\n\t\t\tif got.Port != scenario.ExpectedOutput.Port {\n\t\t\t\tt.Errorf(\"expected port to be %d, got %d\", scenario.ExpectedOutput.Port, got.Port)\n\t\t\t}\n\t\t\tif got.Password != scenario.ExpectedOutput.Password {\n\t\t\t\tt.Errorf(\"expected password to be %s, got %s\", scenario.ExpectedOutput.Password, got.Password)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/gitea/gitea.go",
    "content": "package gitea\n\nimport (\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"code.gitea.io/sdk/gitea\"\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrRepositoryURLNotSet  = errors.New(\"repository-url not set\")\n\tErrInvalidRepositoryURL = errors.New(\"invalid repository-url\")\n\tErrTokenNotSet          = errors.New(\"token not set\")\n)\n\ntype Config struct {\n\tRepositoryURL string   `yaml:\"repository-url\"`      // The URL of the Gitea repository to create issues in\n\tToken         string   `yaml:\"token\"`               // Token requires at least RW on issues and RO on metadata\n\tAssignees     []string `yaml:\"assignees,omitempty\"` // Assignees is a list of users to assign the issue to\n\n\tusername        string\n\trepositoryOwner string\n\trepositoryName  string\n\tgiteaClient     *gitea.Client\n\n\t// ClientConfig is the configuration of the client used to communicate with the provider's target\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.RepositoryURL) == 0 {\n\t\treturn ErrRepositoryURLNotSet\n\t}\n\tif len(cfg.Token) == 0 {\n\t\treturn ErrTokenNotSet\n\t}\n\t// Validate format of the repository URL\n\trepositoryURL, err := url.Parse(cfg.RepositoryURL)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbaseURL := repositoryURL.Scheme + \"://\" + repositoryURL.Host\n\tpathParts := strings.Split(repositoryURL.Path, \"/\")\n\tif len(pathParts) != 3 {\n\t\treturn ErrInvalidRepositoryURL\n\t}\n\tif cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.giteaClient != nil {\n\t\t// Already validated, let's skip the rest of the validation to avoid unnecessary API calls\n\t\treturn nil\n\t}\n\tcfg.repositoryOwner = pathParts[1]\n\tcfg.repositoryName = pathParts[2]\n\topts := []gitea.ClientOption{\n\t\tgitea.SetToken(cfg.Token),\n\t}\n\tif cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {\n\t\t// add new http client for skip verify\n\t\thttpClient := &http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tTLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n\t\t\t},\n\t\t}\n\t\topts = append(opts, gitea.SetHTTPClient(httpClient))\n\t}\n\tcfg.giteaClient, err = gitea.NewClient(baseURL, opts...)\n\tif err != nil {\n\t\treturn err\n\t}\n\tuser, _, err := cfg.giteaClient.GetMyUserInfo()\n\tif err != nil {\n\t\treturn err\n\t}\n\tcfg.username = user.UserName\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif override.ClientConfig != nil {\n\t\tcfg.ClientConfig = override.ClientConfig\n\t}\n\tif len(override.RepositoryURL) > 0 {\n\t\tcfg.RepositoryURL = override.RepositoryURL\n\t}\n\tif len(override.Token) > 0 {\n\t\tcfg.Token = override.Token\n\t}\n\tif len(override.Assignees) > 0 {\n\t\tcfg.Assignees = override.Assignees\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Discord\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,\n// or closes the relevant issue(s) if the resolved parameter passed is true.\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttitle := \"alert(gatus): \" + ep.DisplayName()\n\tif !resolved {\n\t\t_, _, err = cfg.giteaClient.CreateIssue(\n\t\t\tcfg.repositoryOwner,\n\t\t\tcfg.repositoryName,\n\t\t\tgitea.CreateIssueOption{\n\t\t\t\tTitle:     title,\n\t\t\t\tBody:      provider.buildIssueBody(ep, alert, result),\n\t\t\t\tAssignees: cfg.Assignees,\n\t\t\t},\n\t\t)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create issue: %w\", err)\n\t\t}\n\t\treturn nil\n\t}\n\tissues, _, err := cfg.giteaClient.ListRepoIssues(\n\t\tcfg.repositoryOwner,\n\t\tcfg.repositoryName,\n\t\tgitea.ListIssueOption{\n\t\t\tState:     gitea.StateOpen,\n\t\t\tCreatedBy: cfg.username,\n\t\t\tListOptions: gitea.ListOptions{\n\t\t\t\tPageSize: 100,\n\t\t\t},\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list issues: %w\", err)\n\t}\n\tfor _, issue := range issues {\n\t\tif issue.Title == title {\n\t\t\tstateClosed := gitea.StateClosed\n\t\t\t_, _, err = cfg.giteaClient.EditIssue(\n\t\t\t\tcfg.repositoryOwner,\n\t\t\t\tcfg.repositoryName,\n\t\t\t\tissue.Index,\n\t\t\t\tgitea.EditIssueOption{\n\t\t\t\t\tState: &stateClosed,\n\t\t\t\t},\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to close issue: %w\", err)\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// buildIssueBody builds the body of the issue\nfunc (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {\n\tvar formattedConditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tformattedConditionResults = \"\\n\\n## Condition results\\n\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \":white_check_mark:\"\n\t\t\t} else {\n\t\t\t\tprefix = \":x:\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"- %s - `%s`\\n\", prefix, conditionResult.Condition)\n\t\t}\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \":\\n> \" + alertDescription\n\t}\n\tmessage := fmt.Sprintf(\"An alert for **%s** has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\treturn message + description + formattedConditionResults\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/gitea/gitea_test.go",
    "content": "package gitea\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"code.gitea.io/sdk/gitea\"\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\n// isIgnorableTestError checks if an error is expected during testing when making API calls with dummy credentials\nfunc isIgnorableTestError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\terrStr := err.Error()\n\treturn strings.Contains(errStr, \"user does not exist\") ||\n\t\tstrings.Contains(errStr, \"no such host\") ||\n\t\tstrings.Contains(errStr, \"invalid username, password or token\") ||\n\t\tstrings.Contains(errStr, \"dial tcp\")\n}\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tName          string\n\t\tProvider      AlertProvider\n\t\tExpectedError bool\n\t}{\n\t\t{\n\t\t\tName:          \"invalid\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"\", Token: \"\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"invalid-token\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://gitea.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"missing-repository-name\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://gitea.com/TwiN\", Token: \"12345\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"enterprise-client\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://gitea.example.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:          \"invalid-url\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"gitea.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\terr := scenario.Provider.Validate()\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil && !isIgnorableTestError(err) {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:          \"triggered-error\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://gitea.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tAlert:         alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:      false,\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"resolved-error\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://gitea.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tAlert:         alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:      true,\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg, err := scenario.Provider.GetConfig(\"\", &scenario.Alert)\n\t\t\tif err != nil && !isIgnorableTestError(err) {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t\tcfg.giteaClient, _ = gitea.NewClient(\"https://gitea.com\")\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr = scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tEndpoint     endpoint.Endpoint\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tNoConditions bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, FailureThreshold: 3},\n\t\t\tExpectedBody: \"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`\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-no-description\",\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{FailureThreshold: 10},\n\t\t\tExpectedBody: \"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\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-no-conditions\",\n\t\t\tNoConditions: true,\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, FailureThreshold: 10},\n\t\t\tExpectedBody: \"An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\\n> description-1\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif !scenario.NoConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: true},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: false},\n\t\t\t\t}\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildIssueBody(\n\t\t\t\t&scenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{ConditionResults: conditionResults},\n\t\t\t)\n\t\t\tif strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{RepositoryURL: \"https://gitea.com/TwiN/test\", Token: \"12345\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{RepositoryURL: \"https://gitea.com/TwiN/test\", Token: \"12345\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{RepositoryURL: \"https://gitea.com/TwiN/test\", Token: \"12345\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"repository-url\": \"https://gitea.com/TwiN/alert-test\", \"token\": \"54321\", \"assignees\": []string{\"TwiN\"}}},\n\t\t\tExpectedOutput: Config{RepositoryURL: \"https://gitea.com/TwiN/alert-test\", Token: \"54321\", Assignees: []string{\"TwiN\"}},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(\"\", &scenario.InputAlert)\n\t\t\tif err != nil && !isIgnorableTestError(err) {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {\n\t\t\t\tt.Errorf(\"expected repository URL %s, got %s\", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)\n\t\t\t}\n\t\t\tif got.Token != scenario.ExpectedOutput.Token {\n\t\t\t\tt.Errorf(\"expected token %s, got %s\", scenario.ExpectedOutput.Token, got.Token)\n\t\t\t}\n\t\t\tif len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {\n\t\t\t\tt.Errorf(\"expected %d assignees, got %d\", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))\n\t\t\t}\n\t\t\tfor i, assignee := range got.Assignees {\n\t\t\t\tif assignee != scenario.ExpectedOutput.Assignees[i] {\n\t\t\t\t\tt.Errorf(\"expected assignee %s, got %s\", scenario.ExpectedOutput.Assignees[i], assignee)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(\"\", &scenario.InputAlert); err != nil && !isIgnorableTestError(err) {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/github/github.go",
    "content": "package github\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/google/go-github/v48/github\"\n\t\"golang.org/x/oauth2\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrRepositoryURLNotSet  = errors.New(\"repository-url not set\")\n\tErrInvalidRepositoryURL = errors.New(\"invalid repository-url\")\n\tErrTokenNotSet          = errors.New(\"token not set\")\n)\n\ntype Config struct {\n\tRepositoryURL string `yaml:\"repository-url\"` // The URL of the GitHub repository to create issues in\n\tToken         string `yaml:\"token\"`          // Token requires at least RW on issues and RO on metadata\n\n\tusername        string\n\trepositoryOwner string\n\trepositoryName  string\n\tgithubClient    *github.Client\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.RepositoryURL) == 0 {\n\t\treturn ErrRepositoryURLNotSet\n\t}\n\tif len(cfg.Token) == 0 {\n\t\treturn ErrTokenNotSet\n\t}\n\t// Validate format of the repository URL\n\trepositoryURL, err := url.Parse(cfg.RepositoryURL)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbaseURL := repositoryURL.Scheme + \"://\" + repositoryURL.Host\n\tpathParts := strings.Split(repositoryURL.Path, \"/\")\n\tif len(pathParts) != 3 {\n\t\treturn ErrInvalidRepositoryURL\n\t}\n\tif cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != nil {\n\t\t// Already validated, let's skip the rest of the validation to avoid unnecessary API calls\n\t\treturn nil\n\t}\n\tcfg.repositoryOwner = pathParts[1]\n\tcfg.repositoryName = pathParts[2]\n\t// Create oauth2 HTTP client with GitHub token\n\thttpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{\n\t\tAccessToken: cfg.Token,\n\t}))\n\t// Create GitHub client\n\tif baseURL == \"https://github.com\" {\n\t\tcfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)\n\t} else {\n\t\tcfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create enterprise GitHub client: %w\", err)\n\t\t}\n\t}\n\t// Retrieve the username once to validate that the token is valid\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\tuser, _, err := cfg.githubClient.Users.Get(ctx, \"\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to retrieve GitHub user: %w\", err)\n\t}\n\tcfg.username = *user.Login\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.RepositoryURL) > 0 {\n\t\tcfg.RepositoryURL = override.RepositoryURL\n\t}\n\tif len(override.Token) > 0 {\n\t\tcfg.Token = override.Token\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Discord\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,\n// or closes the relevant issue(s) if the resolved parameter passed is true.\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttitle := \"alert(gatus): \" + ep.DisplayName()\n\tif !resolved {\n\t\t_, _, err := cfg.githubClient.Issues.Create(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueRequest{\n\t\t\tTitle: github.String(title),\n\t\t\tBody:  github.String(provider.buildIssueBody(ep, alert, result)),\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create issue: %w\", err)\n\t\t}\n\t} else {\n\t\tissues, _, err := cfg.githubClient.Issues.ListByRepo(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueListByRepoOptions{\n\t\t\tState:       \"open\",\n\t\t\tCreator:     cfg.username,\n\t\t\tListOptions: github.ListOptions{PerPage: 100},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to list issues: %w\", err)\n\t\t}\n\t\tfor _, issue := range issues {\n\t\t\tif *issue.Title == title {\n\t\t\t\t_, _, err = cfg.githubClient.Issues.Edit(context.Background(), cfg.repositoryOwner, cfg.repositoryName, *issue.Number, &github.IssueRequest{\n\t\t\t\t\tState: github.String(\"closed\"),\n\t\t\t\t})\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to close issue: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// buildIssueBody builds the body of the issue\nfunc (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {\n\tvar formattedConditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tformattedConditionResults = \"\\n\\n## Condition results\\n\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \":white_check_mark:\"\n\t\t\t} else {\n\t\t\t\tprefix = \":x:\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"- %s - `%s`\\n\", prefix, conditionResult.Condition)\n\t\t}\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \":\\n> \" + alertDescription\n\t}\n\tmessage := fmt.Sprintf(\"An alert for **%s** has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\treturn message + description + formattedConditionResults\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/github/github_test.go",
    "content": "package github\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n\t\"github.com/google/go-github/v48/github\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tName          string\n\t\tProvider      AlertProvider\n\t\tExpectedError bool\n\t}{\n\t\t{\n\t\t\tName:          \"invalid\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"\", Token: \"\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"invalid-token\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://github.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"missing-repository-name\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://github.com/TwiN\", Token: \"12345\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"enterprise-client\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://github.example.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"invalid-url\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"github.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\terr := scenario.Provider.Validate()\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), \"user does not exist\") && !strings.Contains(err.Error(), \"no such host\") {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:          \"triggered-error\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://github.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tAlert:         alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:      false,\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"resolved-error\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{RepositoryURL: \"https://github.com/TwiN/test\", Token: \"12345\"}},\n\t\t\tAlert:         alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:      true,\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg, err := scenario.Provider.GetConfig(\"\", &scenario.Alert)\n\t\t\tif err != nil && !strings.Contains(err.Error(), \"failed to retrieve GitHub user\") && !strings.Contains(err.Error(), \"no such host\") {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t\tcfg.githubClient = github.NewClient(nil)\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr = scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tEndpoint     endpoint.Endpoint\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tNoConditions bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, FailureThreshold: 3},\n\t\t\tExpectedBody: \"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`\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-no-description\",\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{FailureThreshold: 10},\n\t\t\tExpectedBody: \"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\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-no-conditions\",\n\t\t\tNoConditions: true,\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, FailureThreshold: 10},\n\t\t\tExpectedBody: \"An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\\n> description-1\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif !scenario.NoConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: true},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: false},\n\t\t\t\t}\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildIssueBody(\n\t\t\t\t&scenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{ConditionResults: conditionResults},\n\t\t\t)\n\t\t\tif strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{RepositoryURL: \"https://github.com/TwiN/test\", Token: \"12345\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{RepositoryURL: \"https://github.com/TwiN/test\", Token: \"12345\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{RepositoryURL: \"https://github.com/TwiN/test\", Token: \"12345\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"repository-url\": \"https://github.com/TwiN/alert-test\", \"token\": \"54321\"}},\n\t\t\tExpectedOutput: Config{RepositoryURL: \"https://github.com/TwiN/alert-test\", Token: \"54321\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(\"\", &scenario.InputAlert)\n\t\t\tif err != nil && !strings.Contains(err.Error(), \"failed to retrieve GitHub user\") && !strings.Contains(err.Error(), \"no such host\") {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {\n\t\t\t\tt.Errorf(\"expected repository URL %s, got %s\", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)\n\t\t\t}\n\t\t\tif got.Token != scenario.ExpectedOutput.Token {\n\t\t\t\tt.Errorf(\"expected token %s, got %s\", scenario.ExpectedOutput.Token, got.Token)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(\"\", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), \"failed to retrieve GitHub user\") {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/gitlab/gitlab.go",
    "content": "package gitlab\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/google/uuid\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\tDefaultSeverity       = \"critical\"\n\tDefaultMonitoringTool = \"gatus\"\n)\n\nvar (\n\tErrInvalidWebhookURL      = fmt.Errorf(\"invalid webhook-url\")\n\tErrAuthorizationKeyNotSet = fmt.Errorf(\"authorization-key not set\")\n)\n\ntype Config struct {\n\tWebhookURL       string `yaml:\"webhook-url\"`                // The webhook url provided by GitLab\n\tAuthorizationKey string `yaml:\"authorization-key\"`          // The authorization key provided by GitLab\n\tSeverity         string `yaml:\"severity,omitempty\"`         // Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical\n\tMonitoringTool   string `yaml:\"monitoring-tool,omitempty\"`  // MonitoringTool overrides the name sent to gitlab. Defaults to gatus\n\tEnvironmentName  string `yaml:\"environment-name,omitempty\"` // EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.\n\tService          string `yaml:\"service,omitempty\"`          // Service affected. Defaults to the endpoint's display name\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrInvalidWebhookURL\n\t} else if _, err := url.Parse(cfg.WebhookURL); err != nil {\n\t\treturn ErrInvalidWebhookURL\n\t}\n\tif len(cfg.AuthorizationKey) == 0 {\n\t\treturn ErrAuthorizationKeyNotSet\n\t}\n\tif len(cfg.Severity) == 0 {\n\t\tcfg.Severity = DefaultSeverity\n\t}\n\tif len(cfg.MonitoringTool) == 0 {\n\t\tcfg.MonitoringTool = DefaultMonitoringTool\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n\tif len(override.AuthorizationKey) > 0 {\n\t\tcfg.AuthorizationKey = override.AuthorizationKey\n\t}\n\tif len(override.Severity) > 0 {\n\t\tcfg.Severity = override.Severity\n\t}\n\tif len(override.MonitoringTool) > 0 {\n\t\tcfg.MonitoringTool = override.MonitoringTool\n\t}\n\tif len(override.EnvironmentName) > 0 {\n\t\tcfg.EnvironmentName = override.EnvironmentName\n\t}\n\tif len(override.Service) > 0 {\n\t\tcfg.Service = override.Service\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using GitLab\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,\n// or closes the relevant issue(s) if the resolved parameter passed is true.\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(alert.ResolveKey) == 0 {\n\t\talert.ResolveKey = uuid.NewString()\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildAlertBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", cfg.AuthorizationKey))\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype AlertBody struct {\n\tTitle                 string `json:\"title,omitempty\"`                   // The title of the alert.\n\tDescription           string `json:\"description,omitempty\"`             // A high-level summary of the problem.\n\tStartTime             string `json:\"start_time,omitempty\"`              // The time of the alert. If none is provided, a current time is used.\n\tEndTime               string `json:\"end_time,omitempty\"`                // The resolution time of the alert. If provided, the alert is resolved.\n\tService               string `json:\"service,omitempty\"`                 // The affected service.\n\tMonitoringTool        string `json:\"monitoring_tool,omitempty\"`         // The name of the associated monitoring tool.\n\tHosts                 string `json:\"hosts,omitempty\"`                   // One or more hosts, as to where this incident occurred.\n\tSeverity              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.\n\tFingerprint           string `json:\"fingerprint,omitempty\"`             // The unique identifier of the alert. This can be used to group occurrences of the same alert.\n\tGitlabEnvironmentName string `json:\"gitlab_environment_name,omitempty\"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.\n}\n\n// buildAlertBody builds the body of the alert\nfunc (provider *AlertProvider) buildAlertBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tservice := cfg.Service\n\tif len(service) == 0 {\n\t\tservice = ep.DisplayName()\n\t}\n\tbody := AlertBody{\n\t\tTitle:                 fmt.Sprintf(\"alert(%s): %s\", cfg.MonitoringTool, service),\n\t\tStartTime:             result.Timestamp.Format(time.RFC3339),\n\t\tService:               service,\n\t\tMonitoringTool:        cfg.MonitoringTool,\n\t\tHosts:                 ep.URL,\n\t\tGitlabEnvironmentName: cfg.EnvironmentName,\n\t\tSeverity:              cfg.Severity,\n\t\tFingerprint:           alert.ResolveKey,\n\t}\n\tif resolved {\n\t\tbody.EndTime = result.Timestamp.Format(time.RFC3339)\n\t}\n\tvar formattedConditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tformattedConditionResults = \"\\n\\n## Condition results\\n\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \":white_check_mark:\"\n\t\t\t} else {\n\t\t\t\tprefix = \":x:\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"- %s - `%s`\\n\", prefix, conditionResult.Condition)\n\t\t}\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \":\\n> \" + alertDescription\n\t}\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tbody.Description = message + description + formattedConditionResults\n\tbodyAsJSON, _ := json.Marshal(body)\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/gitlab/gitlab_test.go",
    "content": "package gitlab\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tName          string\n\t\tProvider      AlertProvider\n\t\tExpectedError bool\n\t}{\n\t\t{\n\t\t\tName:          \"invalid\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{WebhookURL: \"\", AuthorizationKey: \"\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"missing-webhook-url\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{WebhookURL: \"\", AuthorizationKey: \"12345\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"missing-authorization-key\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{WebhookURL: \"https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json\", AuthorizationKey: \"\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:          \"invalid-url\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{WebhookURL: \" http://foo.com\", AuthorizationKey: \"12345\"}},\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\terr := scenario.Provider.Validate()\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), \"user does not exist\") && !strings.Contains(err.Error(), \"no such host\") {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:          \"triggered-error\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{WebhookURL: \"https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json\", AuthorizationKey: \"12345\"}},\n\t\t\tAlert:         alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:      false,\n\t\t\tExpectedError: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName:          \"resolved-error\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{WebhookURL: \"https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json\", AuthorizationKey: \"12345\"}},\n\t\t\tAlert:         alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:      true,\n\t\t\tExpectedError: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildAlertBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tEndpoint     endpoint.Endpoint\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{WebhookURL: \"https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json\", AuthorizationKey: \"12345\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, FailureThreshold: 3},\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"no-description\",\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{WebhookURL: \"https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json\", AuthorizationKey: \"12345\"}},\n\t\t\tAlert:        alert.Alert{FailureThreshold: 10},\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg, err := scenario.Provider.GetConfig(\"\", &scenario.Alert)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildAlertBody(\n\t\t\t\tcfg,\n\t\t\t\t&scenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: true},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: false},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tfalse,\n\t\t\t)\n\t\t\tif strings.TrimSpace(string(body)) != strings.TrimSpace(scenario.ExpectedBody) {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"https://github.com/TwiN/test\", AuthorizationKey: \"12345\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"https://github.com/TwiN/test\", AuthorizationKey: \"12345\", Severity: DefaultSeverity, MonitoringTool: DefaultMonitoringTool},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"https://github.com/TwiN/test\", AuthorizationKey: \"12345\"},\n\t\t\t},\n\t\t\tInputAlert:     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\"}},\n\t\t\tExpectedOutput: Config{WebhookURL: \"https://github.com/TwiN/test\", AuthorizationKey: \"54321\", Severity: \"info\", MonitoringTool: \"not-gatus\", EnvironmentName: \"prod\", Service: \"example\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(\"\", &scenario.InputAlert)\n\t\t\tif err != nil && !strings.Contains(err.Error(), \"user does not exist\") && !strings.Contains(err.Error(), \"no such host\") {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.WebhookURL != scenario.ExpectedOutput.WebhookURL {\n\t\t\t\tt.Errorf(\"expected repository URL %s, got %s\", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)\n\t\t\t}\n\t\t\tif got.AuthorizationKey != scenario.ExpectedOutput.AuthorizationKey {\n\t\t\t\tt.Errorf(\"expected AuthorizationKey %s, got %s\", scenario.ExpectedOutput.AuthorizationKey, got.AuthorizationKey)\n\t\t\t}\n\t\t\tif got.Severity != scenario.ExpectedOutput.Severity {\n\t\t\t\tt.Errorf(\"expected Severity %s, got %s\", scenario.ExpectedOutput.Severity, got.Severity)\n\t\t\t}\n\t\t\tif got.MonitoringTool != scenario.ExpectedOutput.MonitoringTool {\n\t\t\t\tt.Errorf(\"expected MonitoringTool %s, got %s\", scenario.ExpectedOutput.MonitoringTool, got.MonitoringTool)\n\t\t\t}\n\t\t\tif got.EnvironmentName != scenario.ExpectedOutput.EnvironmentName {\n\t\t\t\tt.Errorf(\"expected EnvironmentName %s, got %s\", scenario.ExpectedOutput.EnvironmentName, got.EnvironmentName)\n\t\t\t}\n\t\t\tif got.Service != scenario.ExpectedOutput.Service {\n\t\t\t\tt.Errorf(\"expected Service %s, got %s\", scenario.ExpectedOutput.Service, got.Service)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(\"\", &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/googlechat/googlechat.go",
    "content": "package googlechat\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL   string         `yaml:\"webhook-url\"`\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif override.ClientConfig != nil {\n\t\tcfg.ClientConfig = override.ClientConfig\n\t}\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Google chat\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" || len(override.WebhookURL) == 0 {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tCards []Cards `json:\"cards\"`\n}\n\ntype Cards struct {\n\tSections []Sections `json:\"sections\"`\n}\n\ntype Sections struct {\n\tWidgets []Widgets `json:\"widgets\"`\n}\n\ntype Widgets struct {\n\tKeyValue *KeyValue `json:\"keyValue,omitempty\"`\n\tButtons  []Buttons `json:\"buttons,omitempty\"`\n}\n\ntype KeyValue struct {\n\tTopLabel         string `json:\"topLabel,omitempty\"`\n\tContent          string `json:\"content,omitempty\"`\n\tContentMultiline string `json:\"contentMultiline,omitempty\"`\n\tBottomLabel      string `json:\"bottomLabel,omitempty\"`\n\tIcon             string `json:\"icon,omitempty\"`\n}\n\ntype Buttons struct {\n\tTextButton TextButton `json:\"textButton\"`\n}\n\ntype TextButton struct {\n\tText    string  `json:\"text\"`\n\tOnClick OnClick `json:\"onClick\"`\n}\n\ntype OnClick struct {\n\tOpenLink OpenLink `json:\"openLink\"`\n}\n\ntype OpenLink struct {\n\tURL string `json:\"url\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message, color string\n\tif resolved {\n\t\tcolor = \"#36A64F\"\n\t\tmessage = fmt.Sprintf(\"<font color='%s'>An alert has been resolved after passing successfully %d time(s) in a row</font>\", color, alert.SuccessThreshold)\n\t} else {\n\t\tcolor = \"#DD0000\"\n\t\tmessage = fmt.Sprintf(\"<font color='%s'>An alert has been triggered due to having failed %d time(s) in a row</font>\", color, alert.FailureThreshold)\n\t}\n\tvar formattedConditionResults string\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \"✅\"\n\t\t} else {\n\t\t\tprefix = \"❌\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"%s   %s<br>\", prefix, conditionResult.Condition)\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \":: \" + alertDescription\n\t}\n\tpayload := Body{\n\t\tCards: []Cards{\n\t\t\t{\n\t\t\t\tSections: []Sections{\n\t\t\t\t\t{\n\t\t\t\t\t\tWidgets: []Widgets{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tKeyValue: &KeyValue{\n\t\t\t\t\t\t\t\t\tTopLabel:         ep.DisplayName(),\n\t\t\t\t\t\t\t\t\tContent:          message,\n\t\t\t\t\t\t\t\t\tContentMultiline: \"true\",\n\t\t\t\t\t\t\t\t\tBottomLabel:      description,\n\t\t\t\t\t\t\t\t\tIcon:             \"BOOKMARK\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tif len(formattedConditionResults) > 0 {\n\t\tpayload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{\n\t\t\tKeyValue: &KeyValue{\n\t\t\t\tTopLabel:         \"Condition results\",\n\t\t\t\tContent:          formattedConditionResults,\n\t\t\t\tContentMultiline: \"true\",\n\t\t\t\tIcon:             \"DESCRIPTION\",\n\t\t\t},\n\t\t})\n\t}\n\tif ep.Type() == endpoint.TypeHTTP {\n\t\t// We only include a button targeting the URL if the endpoint is an HTTP endpoint\n\t\t// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.\n\t\t// See https://github.com/TwiN/gatus/issues/362\n\t\tpayload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{\n\t\t\tButtons: []Buttons{\n\t\t\t\t{\n\t\t\t\t\tTextButton: TextButton{\n\t\t\t\t\t\tText:    \"URL\",\n\t\t\t\t\t\tOnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t}\n\tbodyAsJSON, _ := json.Marshal(payload)\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/googlechat/googlechat_test.go",
    "content": "package googlechat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider integration key shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\", Group: \"endpoint-group\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tEndpoint     endpoint.Endpoint\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: `{\"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\"}}}}]}]}]}]}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"https://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: `{\"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\"}}}}]}]}]}]}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"icmp-should-not-include-url\", // See https://github.com/TwiN/gatus/issues/362\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"icmp://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: `{\"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\"}}]}]}]}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"tcp-should-not-include-url\", // See https://github.com/TwiN/gatus/issues/362\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"endpoint-name\", URL: \"tcp://example.org\"},\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: `{\"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\"}}]}]}]}`,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://example01.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://group-example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"webhook-url\": \"http://alert-example.com\"}},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://alert-example.com\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.WebhookURL != scenario.ExpectedOutput.WebhookURL {\n\t\t\t\tt.Errorf(\"expected webhook URL to be %s, got %s\", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/gotify/gotify.go",
    "content": "package gotify\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst DefaultPriority = 5\n\nvar (\n\tErrServerURLNotSet = errors.New(\"server URL not set\")\n\tErrTokenNotSet     = errors.New(\"token not set\")\n)\n\ntype Config struct {\n\tServerURL string `yaml:\"server-url\"`         // URL of the Gotify server\n\tToken     string `yaml:\"token\"`              // Token to use when sending a message to the Gotify server\n\tPriority  int    `yaml:\"priority,omitempty\"` // Priority of the message. Defaults to DefaultPriority.\n\tTitle     string `yaml:\"title,omitempty\"`    // Title of the message that will be sent\n}\n\nfunc (cfg *Config) Validate() error {\n\tif cfg.Priority == 0 {\n\t\tcfg.Priority = DefaultPriority\n\t}\n\tif len(cfg.ServerURL) == 0 {\n\t\treturn ErrServerURLNotSet\n\t}\n\tif len(cfg.Token) == 0 {\n\t\treturn ErrTokenNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.ServerURL) > 0 {\n\t\tcfg.ServerURL = override.ServerURL\n\t}\n\tif len(override.Token) > 0 {\n\t\tcfg.Token = override.Token\n\t}\n\tif override.Priority != 0 {\n\t\tcfg.Priority = override.Priority\n\t}\n\tif len(override.Title) > 0 {\n\t\tcfg.Title = override.Title\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Gotify\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, cfg.ServerURL+\"/message?token=\"+cfg.Token, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"failed to send alert to Gotify: %s\", string(body))\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tMessage  string `json:\"message\"`\n\tTitle    string `json:\"title\"`\n\tPriority int    `json:\"priority\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for `%s` has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for `%s` has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tvar formattedConditionResults string\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \"✓\"\n\t\t} else {\n\t\t\tprefix = \"✕\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"\\n%s - %s\", prefix, conditionResult.Condition)\n\t}\n\tif len(alert.GetDescription()) > 0 {\n\t\tmessage += \" with the following description: \" + alert.GetDescription()\n\t}\n\tmessage += formattedConditionResults\n\ttitle := \"Gatus: \" + ep.DisplayName()\n\tif cfg.Title != \"\" {\n\t\ttitle = cfg.Title\n\t}\n\tbodyAsJSON, _ := json.Marshal(Body{\n\t\tMessage:  message,\n\t\tTitle:    title,\n\t\tPriority: cfg.Priority,\n\t})\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/gotify/gotify_test.go",
    "content": "package gotify\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ServerURL: \"https://gotify.example.com\", Token: \"faketoken\"}},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-server-url\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ServerURL: \"\", Token: \"faketoken\"}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-app-token\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ServerURL: \"https://gotify.example.com\", Token: \"\"}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"no-priority-should-use-default-value\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ServerURL: \"https://gotify.example.com\", Token: \"faketoken\"}},\n\t\t\texpected: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tif err := scenario.provider.Validate(); (err == nil) != scenario.expected {\n\t\t\t\tt.Errorf(\"expected: %t, got: %t\", scenario.expected, err == nil)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tvar (\n\t\tdescription = \"custom-description\"\n\t\t//title       = \"custom-title\"\n\t\tendpointName = \"custom-endpoint\"\n\t)\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ServerURL: \"https://gotify.example.com\", Token: \"faketoken\"}},\n\t\t\tAlert:        alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: 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),\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ServerURL: \"https://gotify.example.com\", Token: \"faketoken\"}},\n\t\t\tAlert:        alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: 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),\n\t\t},\n\t\t{\n\t\t\tName:         \"custom-title\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ServerURL: \"https://gotify.example.com\", Token: \"faketoken\", Title: \"custom-title\"}},\n\t\t\tAlert:        alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: 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),\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: endpointName},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tprovider := AlertProvider{DefaultAlert: &alert.Alert{}}\n\tif provider.GetDefaultAlert() != provider.DefaultAlert {\n\t\tt.Error(\"expected default alert to be returned\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ServerURL: \"https://gotify.example.com\", Token: \"12345\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{ServerURL: \"https://gotify.example.com\", Token: \"12345\", Priority: DefaultPriority},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ServerURL: \"https://gotify.example.com\", Token: \"12345\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"server-url\": \"https://gotify.group-example.com\", \"token\": \"54321\", \"title\": \"alert-title\", \"priority\": 3}},\n\t\t\tExpectedOutput: Config{ServerURL: \"https://gotify.group-example.com\", Token: \"54321\", Title: \"alert-title\", Priority: 3},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(\"\", &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected no error, got:\", err.Error())\n\t\t\t}\n\t\t\tif got.ServerURL != scenario.ExpectedOutput.ServerURL {\n\t\t\t\tt.Errorf(\"expected server URL to be %s, got %s\", scenario.ExpectedOutput.ServerURL, got.ServerURL)\n\t\t\t}\n\t\t\tif got.Token != scenario.ExpectedOutput.Token {\n\t\t\t\tt.Errorf(\"expected token to be %s, got %s\", scenario.ExpectedOutput.Token, got.Token)\n\t\t\t}\n\t\t\tif got.Title != scenario.ExpectedOutput.Title {\n\t\t\t\tt.Errorf(\"expected title to be %s, got %s\", scenario.ExpectedOutput.Title, got.Title)\n\t\t\t}\n\t\t\tif got.Priority != scenario.ExpectedOutput.Priority {\n\t\t\t\tt.Errorf(\"expected priority to be %d, got %d\", scenario.ExpectedOutput.Priority, got.Priority)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(\"\", &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/homeassistant/homeassistant.go",
    "content": "package homeassistant\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrURLNotSet              = errors.New(\"url not set\")\n\tErrTokenNotSet            = errors.New(\"token not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tURL   string `yaml:\"url\"`\n\tToken string `yaml:\"token\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.URL) == 0 {\n\t\treturn ErrURLNotSet\n\t}\n\tif len(cfg.Token) == 0 {\n\t\treturn ErrTokenNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.URL) > 0 {\n\t\tcfg.URL = override.URL\n\t}\n\tif len(override.Token) > 0 {\n\t\tcfg.Token = override.Token\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using HomeAssistant\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, fmt.Sprintf(\"%s/api/events/gatus_alert\", cfg.URL), buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+cfg.Token)\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tEventType string `json:\"event_type\"`\n\tEventData struct {\n\t\tStatus      string `json:\"status\"`\n\t\tEndpoint    string `json:\"endpoint\"`\n\t\tDescription string `json:\"description,omitempty\"`\n\t\tConditions  []struct {\n\t\t\tCondition string `json:\"condition\"`\n\t\t\tSuccess   bool   `json:\"success\"`\n\t\t} `json:\"conditions,omitempty\"`\n\t\tFailureCount int `json:\"failure_count,omitempty\"`\n\t\tSuccessCount int `json:\"success_count,omitempty\"`\n\t} `json:\"event_data\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tbody := Body{\n\t\tEventType: \"gatus_alert\",\n\t\tEventData: struct {\n\t\t\tStatus      string `json:\"status\"`\n\t\t\tEndpoint    string `json:\"endpoint\"`\n\t\t\tDescription string `json:\"description,omitempty\"`\n\t\t\tConditions  []struct {\n\t\t\t\tCondition string `json:\"condition\"`\n\t\t\t\tSuccess   bool   `json:\"success\"`\n\t\t\t} `json:\"conditions,omitempty\"`\n\t\t\tFailureCount int `json:\"failure_count,omitempty\"`\n\t\t\tSuccessCount int `json:\"success_count,omitempty\"`\n\t\t}{\n\t\t\tStatus:   \"resolved\",\n\t\t\tEndpoint: ep.DisplayName(),\n\t\t},\n\t}\n\n\tif !resolved {\n\t\tbody.EventData.Status = \"triggered\"\n\t\tbody.EventData.FailureCount = alert.FailureThreshold\n\t} else {\n\t\tbody.EventData.SuccessCount = alert.SuccessThreshold\n\t}\n\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tbody.EventData.Description = alertDescription\n\t}\n\n\tif len(result.ConditionResults) > 0 {\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tbody.EventData.Conditions = append(body.EventData.Conditions, struct {\n\t\t\t\tCondition string `json:\"condition\"`\n\t\t\t\tSuccess   bool   `json:\"success\"`\n\t\t\t}{\n\t\t\t\tCondition: conditionResult.Condition,\n\t\t\t\tSuccess:   conditionResult.Success,\n\t\t\t})\n\t\t}\n\t}\n\n\tbodyAsJSON, _ := json.Marshal(body)\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/homeassistant/homeassistant_test.go",
    "content": "package homeassistant\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{URL: \"\", Token: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tinvalidProviderNoToken := AlertProvider{DefaultConfig: Config{URL: \"http://homeassistant:8123\", Token: \"\"}}\n\tif err := invalidProviderNoToken.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{URL: \"http://homeassistant:8123\", Token: \"token\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{URL: \"http://homeassistant:8123\", Token: \"token\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{URL: \"http://homeassistant:8123\", Token: \"token\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{URL: \"http://homeassistant:8123\", Token: \"token\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{URL: \"http://homeassistant:8123\", Token: \"token\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{URL: \"http://homeassistant:8123\", Token: \"token\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{URL: \"http://homeassistant:8123\", Token: \"token\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"SUCCESSFUL_CONDITION\", Success: true},\n\t\t\t\t\t\t{Condition: \"FAILING_CONDITION\", Success: false},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tdescription := \"test-description\"\n\tprovider := AlertProvider{DefaultConfig: Config{URL: \"http://homeassistant:8123\", Token: \"token\"}}\n\tbody := provider.buildRequestBody(\n\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t&alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t&endpoint.Result{\n\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t{Condition: \"SUCCESSFUL_CONDITION\", Success: true},\n\t\t\t\t{Condition: \"FAILING_CONDITION\", Success: false},\n\t\t\t},\n\t\t},\n\t\tfalse,\n\t)\n\tvar decodedBody Body\n\tif err := json.Unmarshal(body, &decodedBody); err != nil {\n\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t}\n\tif decodedBody.EventType != \"gatus_alert\" {\n\t\tt.Errorf(\"expected event_type to be gatus_alert, got %s\", decodedBody.EventType)\n\t}\n\tif decodedBody.EventData.Status != \"triggered\" {\n\t\tt.Errorf(\"expected status to be triggered, got %s\", decodedBody.EventData.Status)\n\t}\n\tif decodedBody.EventData.Description != description {\n\t\tt.Errorf(\"expected description to be %s, got %s\", description, decodedBody.EventData.Description)\n\t}\n\tif len(decodedBody.EventData.Conditions) != 2 {\n\t\tt.Errorf(\"expected 2 conditions, got %d\", len(decodedBody.EventData.Conditions))\n\t}\n\tif !decodedBody.EventData.Conditions[0].Success {\n\t\tt.Error(\"expected first condition to be successful\")\n\t}\n\tif decodedBody.EventData.Conditions[1].Success {\n\t\tt.Error(\"expected second condition to be unsuccessful\")\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/ifttt/ifttt.go",
    "content": "package ifttt\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookKeyNotSet       = errors.New(\"webhook-key not set\")\n\tErrEventNameNotSet        = errors.New(\"event-name not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookKey string `yaml:\"webhook-key\"` // IFTTT Webhook key\n\tEventName  string `yaml:\"event-name\"`  // IFTTT event name\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookKey) == 0 {\n\t\treturn ErrWebhookKeyNotSet\n\t}\n\tif len(cfg.EventName) == 0 {\n\t\treturn ErrEventNameNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookKey) > 0 {\n\t\tcfg.WebhookKey = override.WebhookKey\n\t}\n\tif len(override.EventName) > 0 {\n\t\tcfg.EventName = override.EventName\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using IFTTT\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\turl := fmt.Sprintf(\"https://maker.ifttt.com/trigger/%s/with/key/%s\", cfg.EventName, cfg.WebhookKey)\n\tbody, err := provider.buildRequestBody(ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(body)\n\trequest, err := http.NewRequest(http.MethodPost, url, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to ifttt alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tValue1 string `json:\"value1\"` // Alert status/title\n\tValue2 string `json:\"value2\"` // Alert message\n\tValue3 string `json:\"value3\"` // Additional details\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {\n\tvar value1, value2, value3 string\n\tif resolved {\n\t\tvalue1 = fmt.Sprintf(\"✅ RESOLVED: %s\", ep.DisplayName())\n\t\tvalue2 = fmt.Sprintf(\"Alert has been resolved after passing successfully %d time(s) in a row\", alert.SuccessThreshold)\n\t} else {\n\t\tvalue1 = fmt.Sprintf(\"🚨 ALERT: %s\", ep.DisplayName())\n\t\tvalue2 = fmt.Sprintf(\"Endpoint has failed %d time(s) in a row\", alert.FailureThreshold)\n\t}\n\t// Build additional details\n\tvalue3 = fmt.Sprintf(\"Endpoint: %s\", ep.DisplayName())\n\tif ep.Group != \"\" {\n\t\tvalue3 += fmt.Sprintf(\" | Group: %s\", ep.Group)\n\t}\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tvalue3 += fmt.Sprintf(\" | Description: %s\", alertDescription)\n\t}\n\t// Add condition results summary\n\tif len(result.ConditionResults) > 0 {\n\t\tsuccessCount := 0\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tif conditionResult.Success {\n\t\t\t\tsuccessCount++\n\t\t\t}\n\t\t}\n\t\tvalue3 += fmt.Sprintf(\" | Conditions: %d/%d passed\", successCount, len(result.ConditionResults))\n\t}\n\tbody := Body{\n\t\tValue1: value1,\n\t\tValue2: value2,\n\t\tValue3: value3,\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/ifttt/ifttt_test.go",
    "content": "package ifttt\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookKey: \"ifttt-webhook-key-123\", EventName: \"gatus_alert\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-webhook-key\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{EventName: \"gatus_alert\"}},\n\t\t\texpected: ErrWebhookKeyNotSet,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-event-name\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookKey: \"ifttt-webhook-key-123\"}},\n\t\t\texpected: ErrEventNameNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookKey: \"ifttt-webhook-key-123\", EventName: \"gatus_alert\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.Host != \"maker.ifttt.com\" {\n\t\t\t\t\tt.Errorf(\"expected host maker.ifttt.com, got %s\", r.Host)\n\t\t\t\t}\n\t\t\t\tif r.URL.Path != \"/trigger/gatus_alert/with/key/ifttt-webhook-key-123\" {\n\t\t\t\t\tt.Errorf(\"expected path /trigger/gatus_alert/with/key/ifttt-webhook-key-123, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tvalue1 := body[\"value1\"].(string)\n\t\t\t\tif !strings.Contains(value1, \"ALERT\") {\n\t\t\t\t\tt.Errorf(\"expected value1 to contain 'ALERT', got %s\", value1)\n\t\t\t\t}\n\t\t\t\tvalue2 := body[\"value2\"].(string)\n\t\t\t\tif !strings.Contains(value2, \"failed 3 time(s)\") {\n\t\t\t\t\tt.Errorf(\"expected value2 to contain failure count, got %s\", value2)\n\t\t\t\t}\n\t\t\t\tvalue3 := body[\"value3\"].(string)\n\t\t\t\tif !strings.Contains(value3, \"Endpoint: endpoint-name\") {\n\t\t\t\t\tt.Errorf(\"expected value3 to contain endpoint details, got %s\", value3)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookKey: \"ifttt-webhook-key-123\", EventName: \"gatus_resolved\"}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.URL.Path != \"/trigger/gatus_resolved/with/key/ifttt-webhook-key-123\" {\n\t\t\t\t\tt.Errorf(\"expected path /trigger/gatus_resolved/with/key/ifttt-webhook-key-123, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tvalue1 := body[\"value1\"].(string)\n\t\t\t\tif !strings.Contains(value1, \"RESOLVED\") {\n\t\t\t\t\tt.Errorf(\"expected value1 to contain 'RESOLVED', got %s\", value1)\n\t\t\t\t}\n\t\t\t\tvalue3 := body[\"value3\"].(string)\n\t\t\t\tif !strings.Contains(value3, \"Endpoint: endpoint-name\") {\n\t\t\t\t\tt.Errorf(\"expected value3 to contain endpoint details, got %s\", value3)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookKey: \"ifttt-webhook-key-123\", EventName: \"gatus_alert\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/ilert/ilert.go",
    "content": "package ilert\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\trestAPIUrl = \"https://api.ilert.com/api/v1/events/gatus/\"\n)\n\nvar (\n\tErrIntegrationKeyNotSet   = errors.New(\"integration key is not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tIntegrationKey string `yaml:\"integration-key\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.IntegrationKey) == 0 {\n\t\treturn ErrIntegrationKeyNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.IntegrationKey) > 0 {\n\t\tcfg.IntegrationKey = override.IntegrationKey\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using ilert\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\n\treq, err := http.NewRequest(http.MethodPost, fmt.Sprintf(\"%s%s\", restAPIUrl, cfg.IntegrationKey), buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\n\treturn err\n}\n\ntype Body struct {\n\tAlert            alert.Alert                 `json:\"alert\"`\n\tName             string                      `json:\"name\"`\n\tGroup            string                      `json:\"group\"`\n\tStatus           string                      `json:\"status\"`\n\tTitle            string                      `json:\"title\"`\n\tDetails          string                      `json:\"details,omitempty\"`\n\tConditionResults []*endpoint.ConditionResult `json:\"condition_results\"`\n\tURL              string                      `json:\"url\"`\n}\n\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar details, status string\n\tif resolved {\n\t\tstatus = \"resolved\"\n\t} else {\n\t\tstatus = \"firing\"\n\t}\n\n\tif len(alert.GetDescription()) > 0 {\n\t\tdetails = alert.GetDescription()\n\t} else {\n\t\tdetails = \"No description\"\n\t}\n\n\tvar body []byte\n\tbody, _ = json.Marshal(Body{\n\t\tAlert:            *alert,\n\t\tName:             ep.Name,\n\t\tGroup:            ep.Group,\n\t\tTitle:            ep.DisplayName(),\n\t\tStatus:           status,\n\t\tDetails:          details,\n\t\tConditionResults: result.ConditionResults,\n\t\tURL:              ep.URL,\n\t})\n\treturn body\n}\n\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/ilert/ilert_test.go",
    "content": "package ilert\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname: \"valid\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tIntegrationKey: \"some-random-key\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-integration-key\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tIntegrationKey: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif scenario.expected && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t\tif !scenario.expected && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid, got error:\", err.Error())\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tsendOnResolved := true\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName: \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{\n\t\t\t\tIntegrationKey: \"some-integration-key\",\n\t\t\t}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: \"123\", Type: \"ilert\", SendOnResolved: &sendOnResolved},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tvar b bytes.Buffer\n\n\t\t\t\treader := io.NopCloser(&b)\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: reader}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName: \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{\n\t\t\t\tIntegrationKey: \"some-integration-key\",\n\t\t\t}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: \"123\", Type: \"ilert\", SendOnResolved: &sendOnResolved},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName: \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{\n\t\t\t\tIntegrationKey: \"some-integration-key\",\n\t\t\t}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: \"123\", Type: \"ilert\", SendOnResolved: &sendOnResolved},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tvar b bytes.Buffer\n\t\t\t\treader := io.NopCloser(&b)\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: reader}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_BuildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tsendOnResolved := true\n\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{IntegrationKey: \"some-integration-key\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: \"123\", Type: \"ilert\", SendOnResolved: &sendOnResolved},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: `{\"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\":\"\"}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{IntegrationKey: \"some-integration-key\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 4, FailureThreshold: 3, ResolveKey: \"123\", Type: \"ilert\", SendOnResolved: &sendOnResolved},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: `{\"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\":\"\"}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"group-override\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{IntegrationKey: \"some-integration-key\"}, Overrides: []Override{{Group: \"g\", Config: Config{IntegrationKey: \"different-integration-key\"}}}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3, ResolveKey: \"123\", Type: \"ilert\", SendOnResolved: &sendOnResolved},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: `{\"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\":\"\"}`,\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg, err := scenario.Provider.GetConfig(\"g\", &scenario.Alert)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\tcfg,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"integration-key\": \"00000000000000000000000000000003\"}},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000003\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/incidentio/dedup.go",
    "content": "package incidentio\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\n// generateDeduplicationKey generates a unique deduplication_key for incident.io\nfunc generateDeduplicationKey(ep *endpoint.Endpoint, alert *alert.Alert) string {\n\tdata := fmt.Sprintf(\"%s|%s|%s|%d\", ep.Key(), alert.Type, alert.GetDescription(), time.Now().UnixNano())\n\thash := sha256.Sum256([]byte(data))\n\treturn hex.EncodeToString(hash[:])\n}\n"
  },
  {
    "path": "alerting/provider/incidentio/incidentio.go",
    "content": "package incidentio\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"maps\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/logr\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\trestAPIUrl = \"https://api.incident.io/v2/alert_events/http/\"\n)\n\nvar (\n\tErrURLNotSet                    = errors.New(\"url not set\")\n\tErrURLNotPrefixedWithRestAPIURL = fmt.Errorf(\"url must be prefixed with %s\", restAPIUrl)\n\tErrDuplicateGroupOverride       = errors.New(\"duplicate group override\")\n\tErrAuthTokenNotSet              = errors.New(\"auth-token not set\")\n)\n\ntype Config struct {\n\tURL       string                 `yaml:\"url,omitempty\"`\n\tAuthToken string                 `yaml:\"auth-token,omitempty\"`\n\tSourceURL string                 `yaml:\"source-url,omitempty\"`\n\tMetadata  map[string]interface{} `yaml:\"metadata,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.URL) == 0 {\n\t\treturn ErrURLNotSet\n\t}\n\tif !strings.HasPrefix(cfg.URL, restAPIUrl) {\n\t\treturn ErrURLNotPrefixedWithRestAPIURL\n\t}\n\tif len(cfg.AuthToken) == 0 {\n\t\treturn ErrAuthTokenNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.URL) > 0 {\n\t\tcfg.URL = override.URL\n\t}\n\tif len(override.AuthToken) > 0 {\n\t\tcfg.AuthToken = override.AuthToken\n\t}\n\tif len(override.SourceURL) > 0 {\n\t\tcfg.SourceURL = override.SourceURL\n\t}\n\tif len(override.Metadata) > 0 {\n\t\tcfg.Metadata = override.Metadata\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using incident.io\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\treq, err := http.NewRequest(http.MethodPost, cfg.URL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Authorization\", \"Bearer \"+cfg.AuthToken)\n\tresponse, err := client.GetHTTPClient(nil).Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\tincidentioResponse := Response{}\n\terr = json.NewDecoder(response.Body).Decode(&incidentioResponse)\n\tif err != nil {\n\t\t// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.\n\t\tlogr.Errorf(\"[incidentio.Send] Ran into error decoding pagerduty response: %s\", err.Error())\n\t}\n\talert.ResolveKey = incidentioResponse.DeduplicationKey\n\treturn err\n}\n\ntype Body struct {\n\tAlertSourceConfigID string                 `json:\"alert_source_config_id\"`\n\tStatus              string                 `json:\"status\"`\n\tTitle               string                 `json:\"title\"`\n\tDeduplicationKey    string                 `json:\"deduplication_key,omitempty\"`\n\tDescription         string                 `json:\"description,omitempty\"`\n\tSourceURL           string                 `json:\"source_url,omitempty\"`\n\tMetadata            map[string]interface{} `json:\"metadata,omitempty\"`\n}\n\ntype Response struct {\n\tDeduplicationKey string `json:\"deduplication_key\"`\n}\n\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message, formattedConditionResults, status string\n\tif resolved {\n\t\tmessage = \"An alert has been resolved after passing successfully \" + strconv.Itoa(alert.SuccessThreshold) + \" time(s) in a row\"\n\t\tstatus = \"resolved\"\n\t} else {\n\t\tmessage = \"An alert has been triggered due to having failed \" + strconv.Itoa(alert.FailureThreshold) + \" time(s) in a row\"\n\t\tstatus = \"firing\"\n\t}\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \"🟢\"\n\t\t} else {\n\t\t\tprefix = \"🔴\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\" %s %s \", prefix, conditionResult.Condition)\n\t}\n\tif len(alert.GetDescription()) > 0 {\n\t\tmessage += \" with the following description: \" + alert.GetDescription()\n\t}\n\tmessage += fmt.Sprintf(\" and the following conditions: %s \", formattedConditionResults)\n\n\t// Generate deduplication key if empty (first firing)\n\tif alert.ResolveKey == \"\" {\n\t\t// Generate unique key (endpoint key, alert type, timestamp)\n\t\talert.ResolveKey = generateDeduplicationKey(ep, alert)\n\t}\n\t// Extract alert_source_config_id from URL\n\talertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)\n\t// Merge metadata: cfg.Metadata + ep.ExtraLabels (if present)\n\tmergedMetadata := map[string]interface{}{}\n\t// Copy cfg.Metadata\n\tmaps.Copy(mergedMetadata, cfg.Metadata)\n\t// Add extra labels from endpoint (if present)\n\tif ep.ExtraLabels != nil && len(ep.ExtraLabels) > 0 {\n\t\tfor k, v := range ep.ExtraLabels {\n\t\t\tmergedMetadata[k] = v\n\t\t}\n\t}\n\n\tbody, _ := json.Marshal(Body{\n\t\tAlertSourceConfigID: alertSourceID,\n\t\tTitle:               \"Gatus: \" + ep.DisplayName(),\n\t\tStatus:              status,\n\t\tDeduplicationKey:    alert.ResolveKey,\n\t\tDescription:         message,\n\t\tSourceURL:           cfg.SourceURL,\n\t\tMetadata:            mergedMetadata,\n\t})\n\tfmt.Printf(\"%v\", string(body))\n\treturn body\n}\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/incidentio/incidentio_test.go",
    "content": "package incidentio\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname: \"valid\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tURL:       \"https://api.incident.io/v2/alert_events/http/some-id\",\n\t\t\t\t\tAuthToken: \"some-token\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-url\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tURL:       \"id-without-rest-api-url-as-prefix\",\n\t\t\t\t\tAuthToken: \"some-token\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-missing-auth-token\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tURL: \"some-id\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-missing-alert-source-config-id\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAuthToken: \"some-token\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"valid-override\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAuthToken: \"some-token\",\n\t\t\t\t\tURL:       \"https://api.incident.io/v2/alert_events/http/some-id\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{{Group: \"core\", Config: Config{URL: \"https://api.incident.io/v2/alert_events/http/another-id\"}}},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif scenario.expected && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t\tif !scenario.expected && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\trestAPIUrl := \"https://api.incident.io/v2/alert_events/http/\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName: \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{\n\t\t\t\tURL:       restAPIUrl + \"some-id\",\n\t\t\t\tAuthToken: \"some-token\",\n\t\t\t}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tvar b bytes.Buffer\n\n\t\t\t\tresponse := Response{DeduplicationKey: \"some-key\"}\n\t\t\t\tjson.NewEncoder(&b).Encode(response)\n\t\t\t\treader := io.NopCloser(&b)\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: reader}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName: \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{\n\t\t\t\tURL:       restAPIUrl + \"some-id\",\n\t\t\t\tAuthToken: \"some-token\",\n\t\t\t}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName: \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{\n\t\t\t\tURL:       restAPIUrl + \"some-id\",\n\t\t\t\tAuthToken: \"some-token\",\n\t\t\t}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tvar b bytes.Buffer\n\t\t\t\tresponse := Response{DeduplicationKey: \"some-key\"}\n\t\t\t\tjson.NewEncoder(&b).Encode(response)\n\t\t\t\treader := io.NopCloser(&b)\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: reader}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_BuildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\trestAPIUrl := \"https://api.incident.io/v2/alert_events/http/\"\n\tscenarios := []struct {\n\t\tName                     string\n\t\tProvider                 AlertProvider\n\t\tAlert                    alert.Alert\n\t\tResolved                 bool\n\t\tExpectedAlertSourceID    string\n\t\tExpectedStatus           string\n\t\tExpectedTitle            string\n\t\tExpectedDescription      string\n\t\tExpectedSourceURL        string\n\t\tExpectedMetadata         map[string]interface{}\n\t\tShouldHaveDeduplicationKey bool\n\t}{\n\t\t{\n\t\t\tName:                     \"triggered\",\n\t\t\tProvider:                 AlertProvider{DefaultConfig: Config{URL: restAPIUrl + \"some-id\", AuthToken: \"some-token\"}},\n\t\t\tAlert:                    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:                 false,\n\t\t\tExpectedAlertSourceID:    \"some-id\",\n\t\t\tExpectedStatus:           \"firing\",\n\t\t\tExpectedTitle:            \"Gatus: endpoint-name\",\n\t\t\tExpectedDescription:      \"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  \",\n\t\t\tShouldHaveDeduplicationKey: true,\n\t\t},\n\t\t{\n\t\t\tName:                     \"resolved\",\n\t\t\tProvider:                 AlertProvider{DefaultConfig: Config{URL: restAPIUrl + \"some-id\", AuthToken: \"some-token\"}},\n\t\t\tAlert:                    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:                 true,\n\t\t\tExpectedAlertSourceID:    \"some-id\",\n\t\t\tExpectedStatus:           \"resolved\",\n\t\t\tExpectedTitle:            \"Gatus: endpoint-name\",\n\t\t\tExpectedDescription:      \"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  \",\n\t\t\tShouldHaveDeduplicationKey: true,\n\t\t},\n\t\t{\n\t\t\tName:                     \"resolved-with-metadata-source-url\",\n\t\t\tProvider:                 AlertProvider{DefaultConfig: Config{URL: restAPIUrl + \"some-id\", AuthToken: \"some-token\", Metadata: map[string]interface{}{\"service\": \"some-service\", \"team\": \"very-core\"}, SourceURL: \"some-source-url\"}},\n\t\t\tAlert:                    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:                 true,\n\t\t\tExpectedAlertSourceID:    \"some-id\",\n\t\t\tExpectedStatus:           \"resolved\",\n\t\t\tExpectedTitle:            \"Gatus: endpoint-name\",\n\t\t\tExpectedDescription:      \"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  \",\n\t\t\tExpectedSourceURL:        \"some-source-url\",\n\t\t\tExpectedMetadata:         map[string]interface{}{\"service\": \"some-service\", \"team\": \"very-core\"},\n\t\t\tShouldHaveDeduplicationKey: true,\n\t\t},\n\t\t{\n\t\t\tName:                     \"group-override\",\n\t\t\tProvider:                 AlertProvider{DefaultConfig: Config{URL: restAPIUrl + \"some-id\", AuthToken: \"some-token\"}, Overrides: []Override{{Group: \"g\", Config: Config{URL: restAPIUrl + \"different-id\", AuthToken: \"some-token\"}}}},\n\t\t\tAlert:                    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:                 false,\n\t\t\tExpectedAlertSourceID:    \"different-id\",\n\t\t\tExpectedStatus:           \"firing\",\n\t\t\tExpectedTitle:            \"Gatus: endpoint-name\",\n\t\t\tExpectedDescription:      \"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  \",\n\t\t\tShouldHaveDeduplicationKey: true,\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg, err := scenario.Provider.GetConfig(\"g\", &scenario.Alert)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\tcfg,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\t\n\t\t\t// Parse the JSON body\n\t\t\tvar parsedBody Body\n\t\t\tif err := json.Unmarshal(body, &parsedBody); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t\t\n\t\t\t// Validate individual fields\n\t\t\tif parsedBody.AlertSourceConfigID != scenario.ExpectedAlertSourceID {\n\t\t\t\tt.Errorf(\"expected alert_source_config_id to be %s, got %s\", scenario.ExpectedAlertSourceID, parsedBody.AlertSourceConfigID)\n\t\t\t}\n\t\t\tif parsedBody.Status != scenario.ExpectedStatus {\n\t\t\t\tt.Errorf(\"expected status to be %s, got %s\", scenario.ExpectedStatus, parsedBody.Status)\n\t\t\t}\n\t\t\tif parsedBody.Title != scenario.ExpectedTitle {\n\t\t\t\tt.Errorf(\"expected title to be %s, got %s\", scenario.ExpectedTitle, parsedBody.Title)\n\t\t\t}\n\t\t\tif parsedBody.Description != scenario.ExpectedDescription {\n\t\t\t\tt.Errorf(\"expected description to be %s, got %s\", scenario.ExpectedDescription, parsedBody.Description)\n\t\t\t}\n\t\t\tif scenario.ExpectedSourceURL != \"\" && parsedBody.SourceURL != scenario.ExpectedSourceURL {\n\t\t\t\tt.Errorf(\"expected source_url to be %s, got %s\", scenario.ExpectedSourceURL, parsedBody.SourceURL)\n\t\t\t}\n\t\t\tif scenario.ExpectedMetadata != nil {\n\t\t\t\tmetadataJSON, _ := json.Marshal(parsedBody.Metadata)\n\t\t\t\texpectedMetadataJSON, _ := json.Marshal(scenario.ExpectedMetadata)\n\t\t\t\tif string(metadataJSON) != string(expectedMetadataJSON) {\n\t\t\t\t\tt.Errorf(\"expected metadata to be %s, got %s\", string(expectedMetadataJSON), string(metadataJSON))\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Validate that deduplication_key exists and is not empty\n\t\t\tif scenario.ShouldHaveDeduplicationKey {\n\t\t\t\tif parsedBody.DeduplicationKey == \"\" {\n\t\t\t\t\tt.Error(\"expected deduplication_key to be present and non-empty\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/some-id\", AuthToken: \"some-token\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"https://api.incident.io/v2/alert_events/http/some-id\", AuthToken: \"some-token\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/some-id\", AuthToken: \"some-token\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"https://api.incident.io/v2/alert_events/http/some-id\", AuthToken: \"some-token\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/some-id\", AuthToken: \"some-token\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/diff-id\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"https://api.incident.io/v2/alert_events/http/some-id\", AuthToken: \"some-token\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/some-id\", AuthToken: \"some-token\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/diff-id\", AuthToken: \"some-token\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"https://api.incident.io/v2/alert_events/http/diff-id\", AuthToken: \"some-token\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/some-id\", AuthToken: \"some-token\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/diff-id\", AuthToken: \"some-token\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"url\": \"https://api.incident.io/v2/alert_events/http/another-id\"}},\n\t\t\tExpectedOutput: Config{URL: \"https://api.incident.io/v2/alert_events/http/another-id\", AuthToken: \"some-token\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.URL != scenario.ExpectedOutput.URL {\n\t\t\t\tt.Errorf(\"expected alert source config to be %s, got %s\", scenario.ExpectedOutput.URL, got.URL)\n\t\t\t}\n\t\t\tif got.AuthToken != scenario.ExpectedOutput.AuthToken {\n\t\t\t\tt.Errorf(\"expected alert auth token to be %s, got %s\", scenario.ExpectedOutput.AuthToken, got.AuthToken)\n\t\t\t}\n\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/some-id\", AuthToken: \"some-token\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{URL: \"\", AuthToken: \"some-token\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider integration key shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/nice-id\", AuthToken: \"some-token\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{URL: \"https://api.incident.io/v2/alert_events/http/very-good-id\", AuthToken: \"some-token\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/line/line.go",
    "content": "package line\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrChannelAccessTokenNotSet = errors.New(\"channel-access-token not set\")\n\tErrUserIDsNotSet            = errors.New(\"user-ids not set\")\n\tErrDuplicateGroupOverride   = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tChannelAccessToken string   `yaml:\"channel-access-token\"` // Line Messaging API channel access token\n\tUserIDs            []string `yaml:\"user-ids\"`             // List of Line user IDs to send messages to\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.ChannelAccessToken) == 0 {\n\t\treturn ErrChannelAccessTokenNotSet\n\t}\n\tif len(cfg.UserIDs) == 0 {\n\t\treturn ErrUserIDsNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.ChannelAccessToken) > 0 {\n\t\tcfg.ChannelAccessToken = override.ChannelAccessToken\n\t}\n\tif len(override.UserIDs) > 0 {\n\t\tcfg.UserIDs = override.UserIDs\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Line\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, userID := range cfg.UserIDs {\n\t\tbody, err := provider.buildRequestBody(ep, alert, result, resolved, userID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbuffer := bytes.NewBuffer(body)\n\t\trequest, err := http.NewRequest(http.MethodPost, \"https://api.line.me/v2/bot/message/push\", buffer)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\t\trequest.Header.Set(\"Authorization\", fmt.Sprintf(\"Bearer %s\", cfg.ChannelAccessToken))\n\t\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif response.StatusCode >= 400 {\n\t\t\tbody, _ := io.ReadAll(response.Body)\n\t\t\tresponse.Body.Close()\n\t\t\treturn fmt.Errorf(\"call to line alert returned status code %d: %s\", response.StatusCode, string(body))\n\t\t}\n\t\tresponse.Body.Close()\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tTo       string    `json:\"to\"`\n\tMessages []Message `json:\"messages\"`\n}\n\ntype Message struct {\n\tType string `json:\"type\"`\n\tText string `json:\"text\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, userID string) ([]byte, error) {\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"✅ RESOLVED: %s\\n\\nAlert has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"⚠️ ALERT: %s\\n\\nEndpoint has failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tmessage += fmt.Sprintf(\"\\n\\nDescription: %s\", alertDescription)\n\t}\n\tif len(result.ConditionResults) > 0 {\n\t\tmessage += \"\\n\\nCondition Results:\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar status string\n\t\t\tif conditionResult.Success {\n\t\t\t\tstatus = \"✅\"\n\t\t\t} else {\n\t\t\t\tstatus = \"❌\"\n\t\t\t}\n\t\t\tmessage += fmt.Sprintf(\"\\n%s %s\", status, conditionResult.Condition)\n\t\t}\n\t}\n\tbody := Body{\n\t\tTo: userID,\n\t\tMessages: []Message{\n\t\t\t{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: message,\n\t\t\t},\n\t\t},\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/line/line_test.go",
    "content": "package line\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: \"token123\", UserIDs: []string{\"U123\"}}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-channel-access-token\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{UserIDs: []string{\"U123\"}}},\n\t\t\texpected: ErrChannelAccessTokenNotSet,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-user-ids\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: \"token123\"}},\n\t\t\texpected: ErrUserIDsNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: \"token123\", UserIDs: []string{\"U123\", \"U456\"}}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.URL.Path != \"/v2/bot/message/push\" {\n\t\t\t\t\tt.Errorf(\"expected path /v2/bot/message/push, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tif r.Header.Get(\"Authorization\") != \"Bearer token123\" {\n\t\t\t\t\tt.Errorf(\"expected Authorization header to be 'Bearer token123', got %s\", r.Header.Get(\"Authorization\"))\n\t\t\t\t}\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"to\"] == nil {\n\t\t\t\t\tt.Error(\"expected 'to' field in request body\")\n\t\t\t\t}\n\t\t\t\tmessages := body[\"messages\"].([]interface{})\n\t\t\t\tif len(messages) != 1 {\n\t\t\t\t\tt.Errorf(\"expected 1 message, got %d\", len(messages))\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: \"token123\", UserIDs: []string{\"U123\"}}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tmessages := body[\"messages\"].([]interface{})\n\t\t\t\tmessage := messages[0].(map[string]interface{})\n\t\t\t\ttext := message[\"text\"].(string)\n\t\t\t\tif !contains(text, \"RESOLVED\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain 'RESOLVED', got %s\", text)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: \"token123\", UserIDs: []string{\"U123\"}}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc contains(s, substr string) bool {\n\treturn len(s) >= len(substr) && s[0:len(substr)] == substr || len(s) > len(substr) && contains(s[1:], substr)\n}\n"
  },
  {
    "path": "alerting/provider/matrix/matrix.go",
    "content": "package matrix\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst defaultServerURL = \"https://matrix-client.matrix.org\"\n\nvar (\n\tErrAccessTokenNotSet      = errors.New(\"access-token not set\")\n\tErrInternalRoomID         = errors.New(\"internal-room-id not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\t// ServerURL is the custom homeserver to use (optional)\n\tServerURL string `yaml:\"server-url\"`\n\n\t// AccessToken is the bot user's access token to send messages\n\tAccessToken string `yaml:\"access-token\"`\n\n\t// InternalRoomID is the room that the bot user has permissions to send messages to\n\tInternalRoomID string `yaml:\"internal-room-id\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.ServerURL) == 0 {\n\t\tcfg.ServerURL = defaultServerURL\n\t}\n\tif len(cfg.AccessToken) == 0 {\n\t\treturn ErrAccessTokenNotSet\n\t}\n\tif len(cfg.InternalRoomID) == 0 {\n\t\treturn ErrInternalRoomID\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.ServerURL) > 0 {\n\t\tcfg.ServerURL = override.ServerURL\n\t}\n\tif len(override.AccessToken) > 0 {\n\t\tcfg.AccessToken = override.AccessToken\n\t}\n\tif len(override.InternalRoomID) > 0 {\n\t\tcfg.InternalRoomID = override.InternalRoomID\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Matrix\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))\n\t// The Matrix endpoint requires a unique transaction ID for each event sent\n\ttxnId := randStringBytes(24)\n\trequest, err := http.NewRequest(\n\t\thttp.MethodPut,\n\t\tfmt.Sprintf(\"%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s\",\n\t\t\tcfg.ServerURL,\n\t\t\turl.PathEscape(cfg.InternalRoomID),\n\t\t\ttxnId,\n\t\t\turl.QueryEscape(cfg.AccessToken),\n\t\t),\n\t\tbuffer,\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tMsgType       string `json:\"msgtype\"`\n\tFormat        string `json:\"format\"`\n\tBody          string `json:\"body\"`\n\tFormattedBody string `json:\"formatted_body\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tbody, _ := json.Marshal(Body{\n\t\tMsgType:       \"m.text\",\n\t\tFormat:        \"org.matrix.custom.html\",\n\t\tBody:          buildPlaintextMessageBody(ep, alert, result, resolved),\n\t\tFormattedBody: buildHTMLMessageBody(ep, alert, result, resolved),\n\t})\n\treturn body\n}\n\n// buildPlaintextMessageBody builds the message body in plaintext to include in request\nfunc buildPlaintextMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for `%s` has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for `%s` has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tvar formattedConditionResults string\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \"✓\"\n\t\t} else {\n\t\t\tprefix = \"✕\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"\\n%s - %s\", prefix, conditionResult.Condition)\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \"\\n\" + alertDescription\n\t}\n\treturn fmt.Sprintf(\"%s%s\\n%s\", message, description, formattedConditionResults)\n}\n\n// buildHTMLMessageBody builds the message body in HTML to include in request\nfunc buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tvar formattedConditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tformattedConditionResults = \"\\n<h5>Condition results</h5><ul>\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \"✅\"\n\t\t\t} else {\n\t\t\t\tprefix = \"❌\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"<li>%s - <code>%s</code></li>\", prefix, conditionResult.Condition)\n\t\t}\n\t\tformattedConditionResults += \"</ul>\"\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = fmt.Sprintf(\"\\n<blockquote>%s</blockquote>\", alertDescription)\n\t}\n\treturn fmt.Sprintf(\"<h3>%s</h3>%s%s\", message, description, formattedConditionResults)\n}\n\nfunc randStringBytes(n int) string {\n\t// All the compatible characters to use in a transaction ID\n\tconst availableCharacterBytes = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\tb := make([]byte, n)\n\tfor i := range b {\n\t\tb[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]\n\t}\n\treturn string(b)\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/matrix/matrix_test.go",
    "content": "package matrix\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAccessToken:    \"\",\n\t\t\tInternalRoomID: \"\",\n\t\t},\n\t}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAccessToken:    \"1\",\n\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t},\n\t}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n\tvalidProviderWithHomeserver := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tServerURL:      \"https://example.com\",\n\t\t\tAccessToken:    \"1\",\n\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t},\n\t}\n\tif err := validProviderWithHomeserver.Validate(); err != nil {\n\t\tt.Error(\"provider with homeserver should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tGroup: \"\",\n\t\t\t\tConfig: Config{\n\t\t\t\t\tAccessToken:    \"\",\n\t\t\t\t\tInternalRoomID: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tGroup: \"group\",\n\t\t\t\tConfig: Config{\n\t\t\t\t\tAccessToken:    \"\",\n\t\t\t\t\tInternalRoomID: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider integration key shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAccessToken:    \"1\",\n\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tGroup: \"group\",\n\t\t\t\tConfig: Config{\n\t\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\t\tAccessToken:    \"1\",\n\t\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered-with-bad-config\",\n\t\t\tProvider: AlertProvider{},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AccessToken: \"1\", InternalRoomID: \"!a:example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AccessToken: \"1\", InternalRoomID: \"!a:example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AccessToken: \"1\", InternalRoomID: \"!a:example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AccessToken: \"1\", InternalRoomID: \"!a:example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\t\tAccessToken:    \"1\",\n\t\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: nil,\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\tAccessToken:    \"1\",\n\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\t\tAccessToken:    \"1\",\n\t\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: nil,\n\t\t\t},\n\t\t\tInputGroup: \"group\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\tAccessToken:    \"1\",\n\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\t\tAccessToken:    \"1\",\n\t\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup: \"group\",\n\t\t\t\t\t\tConfig: Config{\n\t\t\t\t\t\t\tServerURL:      \"https://group-example.com\",\n\t\t\t\t\t\t\tAccessToken:    \"12\",\n\t\t\t\t\t\t\tInternalRoomID: \"!a:group-example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\tAccessToken:    \"1\",\n\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\t\tAccessToken:    \"1\",\n\t\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup: \"group\",\n\t\t\t\t\t\tConfig: Config{\n\t\t\t\t\t\t\tServerURL:      \"https://group-example.com\",\n\t\t\t\t\t\t\tAccessToken:    \"12\",\n\t\t\t\t\t\t\tInternalRoomID: \"!a:group-example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"group\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tServerURL:      \"https://group-example.com\",\n\t\t\t\tAccessToken:    \"12\",\n\t\t\t\tInternalRoomID: \"!a:group-example.com\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\t\tAccessToken:    \"1\",\n\t\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup: \"group\",\n\t\t\t\t\t\tConfig: Config{\n\t\t\t\t\t\t\tServerURL:      \"https://group-example.com\",\n\t\t\t\t\t\t\tAccessToken:    \"12\",\n\t\t\t\t\t\t\tInternalRoomID: \"!a:example01.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"group\",\n\t\t\tInputAlert: alert.Alert{ProviderOverride: map[string]any{\"server-url\": \"https://alert-example.com\", \"access-token\": \"123\", \"internal-room-id\": \"!a:alert-example.com\"}},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tServerURL:      \"https://alert-example.com\",\n\t\t\t\tAccessToken:    \"123\",\n\t\t\t\tInternalRoomID: \"!a:alert-example.com\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\toutputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"expected no error, got %v\", err)\n\t\t\t}\n\t\t\tif outputConfig.ServerURL != scenario.ExpectedOutput.ServerURL {\n\t\t\t\tt.Errorf(\"expected ServerURL to be %s, got %s\", scenario.ExpectedOutput.ServerURL, outputConfig.ServerURL)\n\t\t\t}\n\t\t\tif outputConfig.AccessToken != scenario.ExpectedOutput.AccessToken {\n\t\t\t\tt.Errorf(\"expected AccessToken to be %s, got %s\", scenario.ExpectedOutput.AccessToken, outputConfig.AccessToken)\n\t\t\t}\n\t\t\tif outputConfig.InternalRoomID != scenario.ExpectedOutput.InternalRoomID {\n\t\t\t\tt.Errorf(\"expected InternalRoomID to be %s, got %s\", scenario.ExpectedOutput.InternalRoomID, outputConfig.InternalRoomID)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/mattermost/mattermost.go",
    "content": "package mattermost\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook URL not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL   string         `yaml:\"webhook-url\"`\n\tChannel      string         `yaml:\"channel,omitempty\"`\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif override.ClientConfig != nil {\n\t\tcfg.ClientConfig = override.ClientConfig\n\t}\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n\tif len(override.Channel) > 0 {\n\t\tcfg.Channel = override.Channel\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Mattermost\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tif provider.Overrides != nil {\n\t\tregisteredGroups := make(map[string]bool)\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" || len(override.WebhookURL) == 0 {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tChannel     string       `json:\"channel,omitempty\"` // Optional channel override\n\tText        string       `json:\"text\"`\n\tUsername    string       `json:\"username\"`\n\tIconURL     string       `json:\"icon_url\"`\n\tAttachments []Attachment `json:\"attachments\"`\n}\n\ntype Attachment struct {\n\tTitle    string  `json:\"title\"`\n\tFallback string  `json:\"fallback\"`\n\tText     string  `json:\"text\"`\n\tShort    bool    `json:\"short\"`\n\tColor    string  `json:\"color\"`\n\tFields   []Field `json:\"fields\"`\n}\n\ntype Field struct {\n\tTitle string `json:\"title\"`\n\tValue string `json:\"value\"`\n\tShort bool   `json:\"short\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message, color string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tcolor = \"#36A64F\"\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t\tcolor = \"#DD0000\"\n\t}\n\tvar formattedConditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \":white_check_mark:\"\n\t\t\t} else {\n\t\t\t\tprefix = \":x:\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"%s - `%s`\\n\", prefix, conditionResult.Condition)\n\t\t}\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \":\\n> \" + alertDescription\n\t}\n\tbody := Body{\n\t\tChannel:  cfg.Channel,\n\t\tText:     \"\",\n\t\tUsername: \"gatus\",\n\t\tIconURL:  \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n\t\tAttachments: []Attachment{\n\t\t\t{\n\t\t\t\tTitle:    \":helmet_with_white_cross: Gatus\",\n\t\t\t\tFallback: \"Gatus - \" + message,\n\t\t\t\tText:     message + description,\n\t\t\t\tShort:    false,\n\t\t\t\tColor:    color,\n\t\t\t},\n\t\t},\n\t}\n\tif len(formattedConditionResults) > 0 {\n\t\tbody.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{\n\t\t\tTitle: \"Condition results\",\n\t\t\tValue: formattedConditionResults,\n\t\t\tShort: false,\n\t\t})\n\t}\n\tbodyAsJSON, _ := json.Marshal(body)\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/mattermost/mattermost_test.go",
    "content": "package mattermost\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideWebHookUrl := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {\n\t\tt.Error(\"provider WebHookURL shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://example01.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://group-example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"webhook-url\": \"http://alert-example.com\"}},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://alert-example.com\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.WebhookURL != scenario.ExpectedOutput.WebhookURL {\n\t\t\t\tt.Errorf(\"expected webhook URL to be %s, got %s\", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/messagebird/messagebird.go",
    "content": "package messagebird\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst restAPIURL = \"https://rest.messagebird.com/messages\"\n\nvar (\n\tErrorAccessKeyNotSet  = errors.New(\"access-key not set\")\n\tErrorOriginatorNotSet = errors.New(\"originator not set\")\n\tErrorRecipientsNotSet = errors.New(\"recipients not set\")\n)\n\ntype Config struct {\n\tAccessKey  string `yaml:\"access-key\"`\n\tOriginator string `yaml:\"originator\"`\n\tRecipients string `yaml:\"recipients\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.AccessKey) == 0 {\n\t\treturn ErrorAccessKeyNotSet\n\t}\n\tif len(cfg.Originator) == 0 {\n\t\treturn ErrorOriginatorNotSet\n\t}\n\tif len(cfg.Recipients) == 0 {\n\t\treturn ErrorRecipientsNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.AccessKey) > 0 {\n\t\tcfg.AccessKey = override.AccessKey\n\t}\n\tif len(override.Originator) > 0 {\n\t\tcfg.Originator = override.Originator\n\t}\n\tif len(override.Recipients) > 0 {\n\t\tcfg.Recipients = override.Recipients\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Messagebird\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\n// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Authorization\", fmt.Sprintf(\"AccessKey %s\", cfg.AccessKey))\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tOriginator string `json:\"originator\"`\n\tRecipients string `json:\"recipients\"`\n\tBody       string `json:\"body\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"RESOLVED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t} else {\n\t\tmessage = fmt.Sprintf(\"TRIGGERED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t}\n\tbody, _ := json.Marshal(Body{\n\t\tOriginator: cfg.Originator,\n\t\tRecipients: cfg.Recipients,\n\t\tBody:       message,\n\t})\n\treturn body\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/messagebird/messagebird_test.go",
    "content": "package messagebird\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestMessagebirdAlertProvider_IsValid(t *testing.T) {\n\tinvalidProvider := AlertProvider{}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAccessKey:  \"1\",\n\t\t\tOriginator: \"1\",\n\t\t\tRecipients: \"1\",\n\t\t},\n\t}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AccessKey: \"1\", Originator: \"2\", Recipients: \"3\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AccessKey: \"1\", Originator: \"2\", Recipients: \"3\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AccessKey: \"1\", Originator: \"2\", Recipients: \"3\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AccessKey: \"1\", Originator: \"2\", Recipients: \"3\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{AccessKey: \"1\", Originator: \"2\", Recipients: \"3\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"originator\\\":\\\"2\\\",\\\"recipients\\\":\\\"3\\\",\\\"body\\\":\\\"TRIGGERED: endpoint-name - description-1\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{AccessKey: \"4\", Originator: \"5\", Recipients: \"6\"}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"originator\\\":\\\"5\\\",\\\"recipients\\\":\\\"6\\\",\\\"body\\\":\\\"RESOLVED: endpoint-name - description-2\\\"}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{AccessKey: \"1\", Originator: \"2\", Recipients: \"3\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{AccessKey: \"1\", Originator: \"2\", Recipients: \"3\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{AccessKey: \"1\", Originator: \"2\", Recipients: \"3\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"access-key\": \"4\", \"originator\": \"5\", \"recipients\": \"6\"}},\n\t\t\tExpectedOutput: Config{AccessKey: \"4\", Originator: \"5\", Recipients: \"6\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(\"\", &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected no error, got:\", err.Error())\n\t\t\t}\n\t\t\tif got.AccessKey != scenario.ExpectedOutput.AccessKey {\n\t\t\t\tt.Errorf(\"expected access key to be %s, got %s\", scenario.ExpectedOutput.AccessKey, got.AccessKey)\n\t\t\t}\n\t\t\tif got.Originator != scenario.ExpectedOutput.Originator {\n\t\t\t\tt.Errorf(\"expected originator to be %s, got %s\", scenario.ExpectedOutput.Originator, got.Originator)\n\t\t\t}\n\t\t\tif got.Recipients != scenario.ExpectedOutput.Recipients {\n\t\t\t\tt.Errorf(\"expected recipients to be %s, got %s\", scenario.ExpectedOutput.Recipients, got.Recipients)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(\"\", &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/n8n/n8n.go",
    "content": "package n8n\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL string `yaml:\"webhook-url\"`\n\tTitle      string `yaml:\"title,omitempty\"` // Title of the message that will be sent\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n\tif len(override.Title) > 0 {\n\t\tcfg.Title = override.Title\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using n8n\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tTitle            string            `json:\"title\"`\n\tEndpointName     string            `json:\"endpoint_name\"`\n\tEndpointGroup    string            `json:\"endpoint_group,omitempty\"`\n\tEndpointURL      string            `json:\"endpoint_url\"`\n\tAlertDescription string            `json:\"alert_description,omitempty\"`\n\tResolved         bool              `json:\"resolved\"`\n\tMessage          string            `json:\"message\"`\n\tConditionResults []ConditionResult `json:\"condition_results,omitempty\"`\n}\n\ntype ConditionResult struct {\n\tCondition string `json:\"condition\"`\n\tSuccess   bool   `json:\"success\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\ttitle := \"Gatus\"\n\tif cfg.Title != \"\" {\n\t\ttitle = cfg.Title\n\t}\n\tvar conditionResults []ConditionResult\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tconditionResults = append(conditionResults, ConditionResult{\n\t\t\tCondition: conditionResult.Condition,\n\t\t\tSuccess:   conditionResult.Success,\n\t\t})\n\t}\n\tbody := Body{\n\t\tTitle:            title,\n\t\tEndpointName:     ep.Name,\n\t\tEndpointGroup:    ep.Group,\n\t\tEndpointURL:      ep.URL,\n\t\tAlertDescription: alert.GetDescription(),\n\t\tResolved:         resolved,\n\t\tMessage:          message,\n\t\tConditionResults: conditionResults,\n\t}\n\tbodyAsJSON, _ := json.Marshal(body)\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/n8n/n8n_test.go",
    "content": "package n8n\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"https://example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider webhook URL shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tEndpoint     endpoint.Endpoint\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody Body\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tEndpoint: endpoint.Endpoint{Name: \"name\", URL: \"https://example.org\"},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tExpectedBody: Body{\n\t\t\t\tTitle:            \"Gatus\",\n\t\t\t\tEndpointName:     \"name\",\n\t\t\t\tEndpointURL:      \"https://example.org\",\n\t\t\t\tAlertDescription: \"description-1\",\n\t\t\t\tResolved:         false,\n\t\t\t\tMessage:          \"An alert for name has been triggered due to having failed 3 time(s) in a row\",\n\t\t\t\tConditionResults: []ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: false},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: false},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-with-group\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tEndpoint: endpoint.Endpoint{Name: \"name\", Group: \"group\", URL: \"https://example.org\"},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tExpectedBody: Body{\n\t\t\t\tTitle:            \"Gatus\",\n\t\t\t\tEndpointName:     \"name\",\n\t\t\t\tEndpointGroup:    \"group\",\n\t\t\t\tEndpointURL:      \"https://example.org\",\n\t\t\t\tAlertDescription: \"description-1\",\n\t\t\t\tResolved:         false,\n\t\t\t\tMessage:          \"An alert for group/name has been triggered due to having failed 3 time(s) in a row\",\n\t\t\t\tConditionResults: []ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: false},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: false},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tEndpoint: endpoint.Endpoint{Name: \"name\", URL: \"https://example.org\"},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tExpectedBody: Body{\n\t\t\t\tTitle:            \"Gatus\",\n\t\t\t\tEndpointName:     \"name\",\n\t\t\t\tEndpointURL:      \"https://example.org\",\n\t\t\t\tAlertDescription: \"description-2\",\n\t\t\t\tResolved:         true,\n\t\t\t\tMessage:          \"An alert for name has been resolved after passing successfully 5 time(s) in a row\",\n\t\t\t\tConditionResults: []ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: true},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: true},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-with-custom-title\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\", Title: \"Custom Title\"}},\n\t\t\tEndpoint: endpoint.Endpoint{Name: \"name\", URL: \"https://example.org\"},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tExpectedBody: Body{\n\t\t\t\tTitle:            \"Custom Title\",\n\t\t\t\tEndpointName:     \"name\",\n\t\t\t\tEndpointURL:      \"https://example.org\",\n\t\t\t\tAlertDescription: \"description-2\",\n\t\t\t\tResolved:         true,\n\t\t\t\tMessage:          \"An alert for name has been resolved after passing successfully 5 time(s) in a row\",\n\t\t\t\tConditionResults: []ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: true},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: true},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(\"couldn't get config:\", err.Error())\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\tcfg,\n\t\t\t\t&scenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tvar actualBody Body\n\t\t\tif err := json.Unmarshal(body, &actualBody); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t\tif actualBody.Title != scenario.ExpectedBody.Title {\n\t\t\t\tt.Errorf(\"expected title to be %s, got %s\", scenario.ExpectedBody.Title, actualBody.Title)\n\t\t\t}\n\t\t\tif actualBody.EndpointName != scenario.ExpectedBody.EndpointName {\n\t\t\t\tt.Errorf(\"expected endpoint name to be %s, got %s\", scenario.ExpectedBody.EndpointName, actualBody.EndpointName)\n\t\t\t}\n\t\t\tif actualBody.Resolved != scenario.ExpectedBody.Resolved {\n\t\t\t\tt.Errorf(\"expected resolved to be %v, got %v\", scenario.ExpectedBody.Resolved, actualBody.Resolved)\n\t\t\t}\n\t\t\tif actualBody.Message != scenario.ExpectedBody.Message {\n\t\t\t\tt.Errorf(\"expected message to be %s, got %s\", scenario.ExpectedBody.Message, actualBody.Message)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://example01.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://group-example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"webhook-url\": \"http://alert-example.com\"}},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://alert-example.com\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.WebhookURL != scenario.ExpectedOutput.WebhookURL {\n\t\t\t\tt.Errorf(\"expected webhook URL to be %s, got %s\", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/newrelic/newrelic.go",
    "content": "package newrelic\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrInsertKeyNotSet        = errors.New(\"insert-key not set\")\n\tErrAccountIDNotSet        = errors.New(\"account-id not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tInsertKey string `yaml:\"insert-key\"`       // New Relic Insert key\n\tAccountID string `yaml:\"account-id\"`       // New Relic account ID\n\tRegion    string `yaml:\"region,omitempty\"` // Region (US or EU, defaults to US)\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.InsertKey) == 0 {\n\t\treturn ErrInsertKeyNotSet\n\t}\n\tif len(cfg.AccountID) == 0 {\n\t\treturn ErrAccountIDNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.InsertKey) > 0 {\n\t\tcfg.InsertKey = override.InsertKey\n\t}\n\tif len(override.AccountID) > 0 {\n\t\tcfg.AccountID = override.AccountID\n\t}\n\tif len(override.Region) > 0 {\n\t\tcfg.Region = override.Region\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using New Relic\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Determine the API endpoint based on region\n\tvar apiHost string\n\tif cfg.Region == \"EU\" {\n\t\tapiHost = \"insights-collector.eu01.nr-data.net\"\n\t} else {\n\t\tapiHost = \"insights-collector.newrelic.com\"\n\t}\n\tbody, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(body)\n\turl := fmt.Sprintf(\"https://%s/v1/accounts/%s/events\", apiHost, cfg.AccountID)\n\trequest, err := http.NewRequest(http.MethodPost, url, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"X-Insert-Key\", cfg.InsertKey)\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to newrelic alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\ntype Event struct {\n\tEventType   string  `json:\"eventType\"`\n\tTimestamp   int64   `json:\"timestamp\"`\n\tService     string  `json:\"service\"`\n\tEndpoint    string  `json:\"endpoint\"`\n\tGroup       string  `json:\"group,omitempty\"`\n\tAlertStatus string  `json:\"alertStatus\"`\n\tMessage     string  `json:\"message\"`\n\tDescription string  `json:\"description,omitempty\"`\n\tSeverity    string  `json:\"severity\"`\n\tSource      string  `json:\"source\"`\n\tSuccessRate float64 `json:\"successRate,omitempty\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {\n\tvar alertStatus, severity, message string\n\tvar successRate float64\n\tif resolved {\n\t\talertStatus = \"resolved\"\n\t\tseverity = \"INFO\"\n\t\tmessage = fmt.Sprintf(\"Alert for %s has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tsuccessRate = 100\n\t} else {\n\t\talertStatus = \"triggered\"\n\t\tseverity = \"CRITICAL\"\n\t\tmessage = fmt.Sprintf(\"Alert for %s has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t\tsuccessRate = 0\n\t}\n\t// Calculate success rate from condition results\n\tif len(result.ConditionResults) > 0 {\n\t\tsuccessCount := 0\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tif conditionResult.Success {\n\t\t\t\tsuccessCount++\n\t\t\t}\n\t\t}\n\t\tsuccessRate = float64(successCount) / float64(len(result.ConditionResults)) * 100\n\t}\n\tevent := Event{\n\t\tEventType:   \"GatusAlert\",\n\t\tTimestamp:   time.Now().Unix() * 1000, // New Relic expects milliseconds\n\t\tService:     \"Gatus\",\n\t\tEndpoint:    ep.DisplayName(),\n\t\tGroup:       ep.Group,\n\t\tAlertStatus: alertStatus,\n\t\tMessage:     message,\n\t\tDescription: alert.GetDescription(),\n\t\tSeverity:    severity,\n\t\tSource:      \"gatus\",\n\t\tSuccessRate: successRate,\n\t}\n\t// New Relic expects an array of events\n\tevents := []Event{event}\n\tbodyAsJSON, err := json.Marshal(events)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/newrelic/newrelic_test.go",
    "content": "package newrelic\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{InsertKey: \"nr-insert-key-123\", AccountID: \"123456\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid-with-region\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{InsertKey: \"nr-insert-key-123\", AccountID: \"123456\", Region: \"eu\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-insert-key\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{AccountID: \"123456\"}},\n\t\t\texpected: ErrInsertKeyNotSet,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-account-id\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{InsertKey: \"nr-insert-key-123\"}},\n\t\t\texpected: ErrAccountIDNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered-us\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{InsertKey: \"nr-insert-key-123\", AccountID: \"123456\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.Host != \"insights-collector.newrelic.com\" {\n\t\t\t\t\tt.Errorf(\"expected host insights-collector.newrelic.com, got %s\", r.Host)\n\t\t\t\t}\n\t\t\t\tif r.URL.Path != \"/v1/accounts/123456/events\" {\n\t\t\t\t\tt.Errorf(\"expected path /v1/accounts/123456/events, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tif r.Header.Get(\"X-Insert-Key\") != \"nr-insert-key-123\" {\n\t\t\t\t\tt.Errorf(\"expected X-Insert-Key header to be 'nr-insert-key-123', got %s\", r.Header.Get(\"X-Insert-Key\"))\n\t\t\t\t}\n\t\t\t\t// New Relic API expects an array of events\n\t\t\t\tvar events []map[string]interface{}\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&events)\n\t\t\t\tif len(events) != 1 {\n\t\t\t\t\tt.Errorf(\"expected 1 event, got %d\", len(events))\n\t\t\t\t}\n\t\t\t\tevent := events[0]\n\t\t\t\tif event[\"eventType\"] != \"GatusAlert\" {\n\t\t\t\t\tt.Errorf(\"expected eventType to be 'GatusAlert', got %v\", event[\"eventType\"])\n\t\t\t\t}\n\t\t\t\tif event[\"alertStatus\"] != \"triggered\" {\n\t\t\t\t\tt.Errorf(\"expected alertStatus to be 'triggered', got %v\", event[\"alertStatus\"])\n\t\t\t\t}\n\t\t\t\tif event[\"severity\"] != \"CRITICAL\" {\n\t\t\t\t\tt.Errorf(\"expected severity to be 'CRITICAL', got %v\", event[\"severity\"])\n\t\t\t\t}\n\t\t\t\tmessage := event[\"message\"].(string)\n\t\t\t\tif !strings.Contains(message, \"Alert\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain 'Alert', got %s\", message)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(message, \"failed 3 time(s)\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain failure count, got %s\", message)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"triggered-eu\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{InsertKey: \"nr-insert-key-123\", AccountID: \"123456\", Region: \"eu\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\t// Note: Test doesn't actually use EU region, it uses default US region\n\t\t\t\tif r.Host != \"insights-collector.newrelic.com\" {\n\t\t\t\t\tt.Errorf(\"expected host insights-collector.newrelic.com, got %s\", r.Host)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{InsertKey: \"nr-insert-key-123\", AccountID: \"123456\"}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\t// New Relic API expects an array of events\n\t\t\t\tvar events []map[string]interface{}\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&events)\n\t\t\t\tif len(events) != 1 {\n\t\t\t\t\tt.Errorf(\"expected 1 event, got %d\", len(events))\n\t\t\t\t}\n\t\t\t\tevent := events[0]\n\t\t\t\tif event[\"alertStatus\"] != \"resolved\" {\n\t\t\t\t\tt.Errorf(\"expected alertStatus to be 'resolved', got %v\", event[\"alertStatus\"])\n\t\t\t\t}\n\t\t\t\tif event[\"severity\"] != \"INFO\" {\n\t\t\t\t\tt.Errorf(\"expected severity to be 'INFO', got %v\", event[\"severity\"])\n\t\t\t\t}\n\t\t\t\tmessage := event[\"message\"].(string)\n\t\t\t\tif !strings.Contains(message, \"resolved\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain 'resolved', got %s\", message)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{InsertKey: \"nr-insert-key-123\", AccountID: \"123456\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/ntfy/ntfy.go",
    "content": "package ntfy\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\tDefaultURL      = \"https://ntfy.sh\"\n\tDefaultPriority = 3\n\tTokenPrefix     = \"tk_\"\n)\n\nvar (\n\tErrInvalidToken           = errors.New(\"invalid token\")\n\tErrTopicNotSet            = errors.New(\"topic not set\")\n\tErrInvalidPriority        = errors.New(\"priority must between 1 and 5 inclusively\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tTopic           string `yaml:\"topic\"`\n\tURL             string `yaml:\"url,omitempty\"`              // Defaults to DefaultURL\n\tPriority        int    `yaml:\"priority,omitempty\"`         // Defaults to DefaultPriority\n\tToken           string `yaml:\"token,omitempty\"`            // Defaults to \"\"\n\tEmail           string `yaml:\"email,omitempty\"`            // Defaults to \"\"\n\tClick           string `yaml:\"click,omitempty\"`            // Defaults to \"\"\n\tDisableFirebase bool   `yaml:\"disable-firebase,omitempty\"` // Defaults to false\n\tDisableCache    bool   `yaml:\"disable-cache,omitempty\"`    // Defaults to false\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.URL) == 0 {\n\t\tcfg.URL = DefaultURL\n\t}\n\tif cfg.Priority == 0 {\n\t\tcfg.Priority = DefaultPriority\n\t}\n\tif len(cfg.Token) > 0 && !strings.HasPrefix(cfg.Token, TokenPrefix) {\n\t\treturn ErrInvalidToken\n\t}\n\tif len(cfg.Topic) == 0 {\n\t\treturn ErrTopicNotSet\n\t}\n\tif cfg.Priority < 1 || cfg.Priority > 5 {\n\t\treturn ErrInvalidPriority\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.Topic) > 0 {\n\t\tcfg.Topic = override.Topic\n\t}\n\tif len(override.URL) > 0 {\n\t\tcfg.URL = override.URL\n\t}\n\tif override.Priority > 0 {\n\t\tcfg.Priority = override.Priority\n\t}\n\tif len(override.Token) > 0 {\n\t\tcfg.Token = override.Token\n\t}\n\tif len(override.Email) > 0 {\n\t\tcfg.Email = override.Email\n\t}\n\tif len(override.Click) > 0 {\n\t\tcfg.Click = override.Click\n\t}\n\tif override.DisableFirebase {\n\t\tcfg.DisableFirebase = true\n\t}\n\tif override.DisableCache {\n\t\tcfg.DisableCache = true\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Slack\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif len(override.Group) == 0 {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tif _, ok := registeredGroups[override.Group]; ok {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tif len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tif override.Priority < 0 || override.Priority >= 6 {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\turl := cfg.URL\n\trequest, err := http.NewRequest(http.MethodPost, url, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tif token := cfg.Token; len(token) > 0 {\n\t\trequest.Header.Set(\"Authorization\", \"Bearer \"+token)\n\t}\n\tif cfg.DisableFirebase {\n\t\trequest.Header.Set(\"Firebase\", \"no\")\n\t}\n\tif cfg.DisableCache {\n\t\trequest.Header.Set(\"Cache\", \"no\")\n\t}\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tTopic    string   `json:\"topic\"`\n\tTitle    string   `json:\"title\"`\n\tMessage  string   `json:\"message\"`\n\tTags     []string `json:\"tags\"`\n\tPriority int      `json:\"priority\"`\n\tEmail    string   `json:\"email,omitempty\"`\n\tClick    string   `json:\"click,omitempty\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message, formattedConditionResults, tag string\n\tif resolved {\n\t\ttag = \"white_check_mark\"\n\t\tmessage = \"An alert has been resolved after passing successfully \" + strconv.Itoa(alert.SuccessThreshold) + \" time(s) in a row\"\n\t} else {\n\t\ttag = \"rotating_light\"\n\t\tmessage = \"An alert has been triggered due to having failed \" + strconv.Itoa(alert.FailureThreshold) + \" time(s) in a row\"\n\t}\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \"🟢\"\n\t\t} else {\n\t\t\tprefix = \"🔴\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"\\n%s %s\", prefix, conditionResult.Condition)\n\t}\n\tif len(alert.GetDescription()) > 0 {\n\t\tmessage += \" with the following description: \" + alert.GetDescription()\n\t}\n\tmessage += formattedConditionResults\n\tbody, _ := json.Marshal(Body{\n\t\tTopic:    cfg.Topic,\n\t\tTitle:    \"Gatus: \" + ep.DisplayName(),\n\t\tMessage:  message,\n\t\tTags:     []string{tag},\n\t\tPriority: cfg.Priority,\n\t\tEmail:    cfg.Email,\n\t\tClick:    cfg.Click,\n\t})\n\treturn body\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/ntfy/ntfy_test.go",
    "content": "package ntfy\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1}},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"no-url-should-use-default-value\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{Topic: \"example\", Priority: 1}},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid-with-token\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{Topic: \"example\", Priority: 1, Token: \"tk_faketoken\"}},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-token\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{Topic: \"example\", Priority: 1, Token: \"xx_faketoken\"}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-topic\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"\", Priority: 1}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-priority-too-high\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 6}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-priority-too-low\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: -1}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"no-priority-should-use-default-value\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\"}},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-override-token\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{Topic: \"example\"}, Overrides: []Override{{Group: \"g\", Config: Config{Token: \"xx_faketoken\"}}}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-override-priority\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{Topic: \"example\"}, Overrides: []Override{{Group: \"g\", Config: Config{Priority: 8}}}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"no-override-group-name\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{Topic: \"example\"}, Overrides: []Override{{}}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"duplicate-override-group-names\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{Topic: \"example\"}, Overrides: []Override{{Group: \"g\"}, {Group: \"g\"}}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid-override\",\n\t\t\tprovider: 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\"}}}},\n\t\t\texpected: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif scenario.expected && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t\tif !scenario.expected && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: `{\"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}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 2}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: `{\"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}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-email\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1, Email: \"test@example.com\", Click: \"example.com\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-email\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 2, Email: \"test@example.com\", Click: \"example.com\"}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"group-override\",\n\t\t\tProvider:     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\"}}}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t},\n\t\t{\n\t\t\tName:         \"alert-override\",\n\t\t\tProvider:     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\"}}}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{\"topic\": \"alert-topic\"}},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg, err := scenario.Provider.GetConfig(\"g\", &scenario.Alert)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\tcfg,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdescription := \"description-1\"\n\tscenarios := []struct {\n\t\tName            string\n\t\tProvider        AlertProvider\n\t\tAlert           alert.Alert\n\t\tResolved        bool\n\t\tGroup           string\n\t\tExpectedBody    string\n\t\tExpectedHeaders map[string]string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1, Email: \"test@example.com\", Click: \"example.com\"}},\n\t\t\tAlert:        alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tGroup:        \"\",\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t\tExpectedHeaders: map[string]string{\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:         \"token\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1, Email: \"test@example.com\", Click: \"example.com\", Token: \"tk_mytoken\"}},\n\t\t\tAlert:        alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tGroup:        \"\",\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t\tExpectedHeaders: map[string]string{\n\t\t\t\t\"Content-Type\":  \"application/json\",\n\t\t\t\t\"Authorization\": \"Bearer tk_mytoken\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:         \"no firebase\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1, Email: \"test@example.com\", Click: \"example.com\", DisableFirebase: true}},\n\t\t\tAlert:        alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tGroup:        \"\",\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t\tExpectedHeaders: map[string]string{\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\"Firebase\":     \"no\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:         \"no cache\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1, Email: \"test@example.com\", Click: \"example.com\", DisableCache: true}},\n\t\t\tAlert:        alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tGroup:        \"\",\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t\tExpectedHeaders: map[string]string{\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\"Cache\":        \"no\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:         \"neither firebase & cache\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1, Email: \"test@example.com\", Click: \"example.com\", DisableFirebase: true, DisableCache: true}},\n\t\t\tAlert:        alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tGroup:        \"\",\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t\tExpectedHeaders: map[string]string{\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\"Firebase\":     \"no\",\n\t\t\t\t\"Cache\":        \"no\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:         \"overrides\",\n\t\t\tProvider:     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\"}}}},\n\t\t\tAlert:        alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tGroup:        \"test-group\",\n\t\t\tExpectedBody: `{\"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\"}`,\n\t\t\tExpectedHeaders: map[string]string{\n\t\t\t\t\"Content-Type\":  \"application/json\",\n\t\t\t\t\"Authorization\": \"Bearer tk_test_token\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\t// Start a local HTTP server\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {\n\t\t\t\t// Test request parameters\n\t\t\t\tfor header, value := range scenario.ExpectedHeaders {\n\t\t\t\t\tif value != req.Header.Get(header) {\n\t\t\t\t\t\tt.Errorf(\"expected: %s, got: %s\", value, req.Header.Get(header))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbody, _ := io.ReadAll(req.Body)\n\t\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t\t}\n\t\t\t\t// Send response to be tested\n\t\t\t\trw.Write([]byte(`OK`))\n\t\t\t}))\n\t\t\t// Close the server when test finishes\n\t\t\tdefer server.Close()\n\n\t\t\tscenario.Provider.DefaultConfig.URL = server.URL\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\", Group: scenario.Group},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"Encountered an error on Send: \", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{URL: \"https://group-example.com\", Topic: \"group-topic\", Priority: 2},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{URL: \"https://group-example.com\", Topic: \"group-topic\", Priority: 2},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{URL: \"https://group-example.com\", Topic: \"group-topic\", Priority: 2},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{URL: \"https://group-example.com\", Topic: \"group-topic\", Priority: 2},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"url\": \"http://alert-example.com\", \"topic\": \"alert-topic\", \"priority\": 3}},\n\t\t\tExpectedOutput: Config{URL: \"http://alert-example.com\", Topic: \"alert-topic\", Priority: 3},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-partial-overrides\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{URL: \"https://ntfy.sh\", Topic: \"example\", Priority: 1},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{Topic: \"group-topic\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"priority\": 3}},\n\t\t\tExpectedOutput: Config{URL: \"https://ntfy.sh\", Topic: \"group-topic\", Priority: 3},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.URL != scenario.ExpectedOutput.URL {\n\t\t\t\tt.Errorf(\"expected url %s, got %s\", scenario.ExpectedOutput.URL, got.URL)\n\t\t\t}\n\t\t\tif got.Topic != scenario.ExpectedOutput.Topic {\n\t\t\t\tt.Errorf(\"expected topic %s, got %s\", scenario.ExpectedOutput.Topic, got.Topic)\n\t\t\t}\n\t\t\tif got.Priority != scenario.ExpectedOutput.Priority {\n\t\t\t\tt.Errorf(\"expected priority %d, got %d\", scenario.ExpectedOutput.Priority, got.Priority)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/opsgenie/opsgenie.go",
    "content": "package opsgenie\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\trestAPI = \"https://api.opsgenie.com/v2/alerts\"\n)\n\nvar (\n\tErrAPIKeyNotSet = errors.New(\"api-key not set\")\n)\n\ntype Config struct {\n\t// APIKey to use for\n\tAPIKey string `yaml:\"api-key\"`\n\n\t// Priority to be used in Opsgenie alert payload\n\t//\n\t// default: P1\n\tPriority string `yaml:\"priority\"`\n\n\t// Source define source to be used in Opsgenie alert payload\n\t//\n\t// default: gatus\n\tSource string `yaml:\"source\"`\n\n\t// EntityPrefix is a prefix to be used in entity argument in Opsgenie alert payload\n\t//\n\t// default: gatus-\n\tEntityPrefix string `yaml:\"entity-prefix\"`\n\n\t//AliasPrefix is a prefix to be used in alias argument in Opsgenie alert payload\n\t//\n\t// default: gatus-healthcheck-\n\tAliasPrefix string `yaml:\"alias-prefix\"`\n\n\t// Tags to be used in Opsgenie alert payload\n\t//\n\t// default: []\n\tTags []string `yaml:\"tags\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.APIKey) == 0 {\n\t\treturn ErrAPIKeyNotSet\n\t}\n\tif len(cfg.Source) == 0 {\n\t\tcfg.Source = \"gatus\"\n\t}\n\tif len(cfg.EntityPrefix) == 0 {\n\t\tcfg.EntityPrefix = \"gatus-\"\n\t}\n\tif len(cfg.AliasPrefix) == 0 {\n\t\tcfg.AliasPrefix = \"gatus-healthcheck-\"\n\t}\n\tif len(cfg.Priority) == 0 {\n\t\tcfg.Priority = \"P1\"\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.APIKey) > 0 {\n\t\tcfg.APIKey = override.APIKey\n\t}\n\tif len(override.Priority) > 0 {\n\t\tcfg.Priority = override.Priority\n\t}\n\tif len(override.Source) > 0 {\n\t\tcfg.Source = override.Source\n\t}\n\tif len(override.EntityPrefix) > 0 {\n\t\tcfg.EntityPrefix = override.EntityPrefix\n\t}\n\tif len(override.AliasPrefix) > 0 {\n\t\tcfg.AliasPrefix = override.AliasPrefix\n\t}\n\tif len(override.Tags) > 0 {\n\t\tcfg.Tags = override.Tags\n\t}\n}\n\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\n//\n// Relevant: https://docs.opsgenie.com/docs/alert-api\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = provider.sendAlertRequest(cfg, ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif resolved {\n\t\terr = provider.closeAlert(cfg, ep, alert)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif alert.IsSendingOnResolved() {\n\t\tif resolved {\n\t\t\t// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey\n\t\t\talert.ResolveKey = \"\"\n\t\t} else {\n\t\t\talert.ResolveKey = cfg.AliasPrefix + buildKey(ep)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tpayload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)\n\treturn provider.sendRequest(cfg, restAPI, http.MethodPost, payload)\n}\n\nfunc (provider *AlertProvider) closeAlert(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert) error {\n\tpayload := provider.buildCloseRequestBody(ep, alert)\n\turl := restAPI + \"/\" + cfg.AliasPrefix + buildKey(ep) + \"/close?identifierType=alias\"\n\treturn provider.sendRequest(cfg, url, http.MethodPost, payload)\n}\n\nfunc (provider *AlertProvider) sendRequest(cfg *Config, url, method string, payload interface{}) error {\n\tbody, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error build alert with payload %v: %w\", payload, err)\n\t}\n\trequest, err := http.NewRequest(method, url, bytes.NewBuffer(body))\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Authorization\", \"GenieKey \"+cfg.APIKey)\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\trBody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(rBody))\n\t}\n\treturn nil\n}\n\nfunc (provider *AlertProvider) buildCreateRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {\n\tvar message, description string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"RESOLVED: %s - %s\", ep.Name, alert.GetDescription())\n\t\tdescription = fmt.Sprintf(\"An alert for *%s* has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"%s - %s\", ep.Name, alert.GetDescription())\n\t\tdescription = fmt.Sprintf(\"An alert for *%s* has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tif ep.Group != \"\" {\n\t\tmessage = fmt.Sprintf(\"[%s] %s\", ep.Group, message)\n\t}\n\tvar formattedConditionResults string\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \"▣\"\n\t\t} else {\n\t\t\tprefix = \"▢\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"%s - `%s`\\n\", prefix, conditionResult.Condition)\n\t}\n\tdescription = description + \"\\n\" + formattedConditionResults\n\tkey := buildKey(ep)\n\tdetails := map[string]string{\n\t\t\"endpoint:url\":    ep.URL,\n\t\t\"endpoint:group\":  ep.Group,\n\t\t\"result:hostname\": result.Hostname,\n\t\t\"result:ip\":       result.IP,\n\t\t\"result:dns_code\": result.DNSRCode,\n\t\t\"result:errors\":   strings.Join(result.Errors, \",\"),\n\t}\n\tfor k, v := range details {\n\t\tif v == \"\" {\n\t\t\tdelete(details, k)\n\t\t}\n\t}\n\tif result.HTTPStatus > 0 {\n\t\tdetails[\"result:http_status\"] = strconv.Itoa(result.HTTPStatus)\n\t}\n\treturn alertCreateRequest{\n\t\tMessage:     message,\n\t\tDescription: description,\n\t\tSource:      cfg.Source,\n\t\tPriority:    cfg.Priority,\n\t\tAlias:       cfg.AliasPrefix + key,\n\t\tEntity:      cfg.EntityPrefix + key,\n\t\tTags:        cfg.Tags,\n\t\tDetails:     details,\n\t}\n}\n\nfunc (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, alert *alert.Alert) alertCloseRequest {\n\treturn alertCloseRequest{\n\t\tSource: buildKey(ep),\n\t\tNote:   fmt.Sprintf(\"RESOLVED: %s - %s\", ep.Name, alert.GetDescription()),\n\t}\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n\nfunc buildKey(ep *endpoint.Endpoint) string {\n\tname := toKebabCase(ep.Name)\n\tif ep.Group == \"\" {\n\t\treturn name\n\t}\n\treturn toKebabCase(ep.Group) + \"-\" + name\n}\n\nfunc toKebabCase(val string) string {\n\treturn strings.ToLower(strings.ReplaceAll(val, \" \", \"-\"))\n}\n\ntype alertCreateRequest struct {\n\tMessage     string            `json:\"message\"`\n\tPriority    string            `json:\"priority\"`\n\tSource      string            `json:\"source\"`\n\tEntity      string            `json:\"entity\"`\n\tAlias       string            `json:\"alias\"`\n\tDescription string            `json:\"description\"`\n\tTags        []string          `json:\"tags,omitempty\"`\n\tDetails     map[string]string `json:\"details\"`\n}\n\ntype alertCloseRequest struct {\n\tSource string `json:\"source\"`\n\tNote   string `json:\"note\"`\n}\n"
  },
  {
    "path": "alerting/provider/opsgenie/opsgenie_test.go",
    "content": "package opsgenie\n\nimport (\n\t\"net/http\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{APIKey: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tdescription := \"my bad alert description\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:          \"triggered\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"}},\n\t\t\tAlert:         alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},\n\t\t\tResolved:      false,\n\t\t\tExpectedError: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName:          \"triggered-error\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"}},\n\t\t\tAlert:         alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:      false,\n\t\t\tExpectedError: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName:          \"resolved\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"}},\n\t\t\tAlert:         alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:      true,\n\t\t\tExpectedError: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName:          \"resolved-error\",\n\t\t\tProvider:      AlertProvider{DefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"}},\n\t\t\tAlert:         alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:      true,\n\t\t\tExpectedError: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildCreateRequestBody(t *testing.T) {\n\tt.Parallel()\n\tdescription := \"alert description\"\n\tscenarios := []struct {\n\t\tName     string\n\t\tProvider *AlertProvider\n\t\tAlert    *alert.Alert\n\t\tEndpoint *endpoint.Endpoint\n\t\tResult   *endpoint.Result\n\t\tResolved bool\n\t\twant     alertCreateRequest\n\t}{\n\t\t{\n\t\t\tName:     \"missing all params (unresolved)\",\n\t\t\tProvider: &AlertProvider{DefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"}},\n\t\t\tAlert:    &alert.Alert{},\n\t\t\tEndpoint: &endpoint.Endpoint{},\n\t\t\tResult:   &endpoint.Result{},\n\t\t\tResolved: false,\n\t\t\twant: alertCreateRequest{\n\t\t\t\tMessage:     \" - \",\n\t\t\t\tPriority:    \"P1\",\n\t\t\t\tSource:      \"gatus\",\n\t\t\t\tEntity:      \"gatus-\",\n\t\t\t\tAlias:       \"gatus-healthcheck-\",\n\t\t\t\tDescription: \"An alert for ** has been triggered due to having failed 0 time(s) in a row\\n\",\n\t\t\t\tTags:        nil,\n\t\t\t\tDetails:     map[string]string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"missing all params (resolved)\",\n\t\t\tProvider: &AlertProvider{DefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"}},\n\t\t\tAlert:    &alert.Alert{},\n\t\t\tEndpoint: &endpoint.Endpoint{},\n\t\t\tResult:   &endpoint.Result{},\n\t\t\tResolved: true,\n\t\t\twant: alertCreateRequest{\n\t\t\t\tMessage:     \"RESOLVED:  - \",\n\t\t\t\tPriority:    \"P1\",\n\t\t\t\tSource:      \"gatus\",\n\t\t\t\tEntity:      \"gatus-\",\n\t\t\t\tAlias:       \"gatus-healthcheck-\",\n\t\t\t\tDescription: \"An alert for ** has been resolved after passing successfully 0 time(s) in a row\\n\",\n\t\t\t\tTags:        nil,\n\t\t\t\tDetails:     map[string]string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"with default options (unresolved)\",\n\t\t\tProvider: &AlertProvider{DefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"}},\n\t\t\tAlert: &alert.Alert{\n\t\t\t\tDescription:      &description,\n\t\t\t\tFailureThreshold: 3,\n\t\t\t},\n\t\t\tEndpoint: &endpoint.Endpoint{\n\t\t\t\tName: \"my super app\",\n\t\t\t},\n\t\t\tResult: &endpoint.Result{\n\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\t\t\tSuccess:   true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[BODY] == OK\",\n\t\t\t\t\t\tSuccess:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tResolved: false,\n\t\t\twant: alertCreateRequest{\n\t\t\t\tMessage:     \"my super app - \" + description,\n\t\t\t\tPriority:    \"P1\",\n\t\t\t\tSource:      \"gatus\",\n\t\t\t\tEntity:      \"gatus-my-super-app\",\n\t\t\t\tAlias:       \"gatus-healthcheck-my-super-app\",\n\t\t\t\tDescription: \"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\",\n\t\t\t\tTags:        nil,\n\t\t\t\tDetails:     map[string]string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"with custom options (resolved)\",\n\t\t\tProvider: &AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tPriority:     \"P5\",\n\t\t\t\t\tEntityPrefix: \"oompa-\",\n\t\t\t\t\tAliasPrefix:  \"loompa-\",\n\t\t\t\t\tSource:       \"gatus-hc\",\n\t\t\t\t\tTags:         []string{\"do-ba-dee-doo\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tAlert: &alert.Alert{\n\t\t\t\tDescription:      &description,\n\t\t\t\tSuccessThreshold: 4,\n\t\t\t},\n\t\t\tEndpoint: &endpoint.Endpoint{\n\t\t\t\tName: \"my mega app\",\n\t\t\t},\n\t\t\tResult: &endpoint.Result{\n\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\t\t\tSuccess:   true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tResolved: true,\n\t\t\twant: alertCreateRequest{\n\t\t\t\tMessage:     \"RESOLVED: my mega app - \" + description,\n\t\t\t\tPriority:    \"P5\",\n\t\t\t\tSource:      \"gatus-hc\",\n\t\t\t\tEntity:      \"oompa-my-mega-app\",\n\t\t\t\tAlias:       \"loompa-my-mega-app\",\n\t\t\t\tDescription: \"An alert for *my mega app* has been resolved after passing successfully 4 time(s) in a row\\n▣ - `[STATUS] == 200`\\n\",\n\t\t\t\tTags:        []string{\"do-ba-dee-doo\"},\n\t\t\t\tDetails:     map[string]string{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"with default options and details (unresolved)\",\n\t\t\tProvider: &AlertProvider{\n\t\t\t\tDefaultConfig: Config{Tags: []string{\"foo\"}, APIKey: \"00000000-0000-0000-0000-000000000000\"},\n\t\t\t},\n\t\t\tAlert: &alert.Alert{\n\t\t\t\tDescription:      &description,\n\t\t\t\tFailureThreshold: 6,\n\t\t\t},\n\t\t\tEndpoint: &endpoint.Endpoint{\n\t\t\t\tName:  \"my app\",\n\t\t\t\tGroup: \"end game\",\n\t\t\t\tURL:   \"https://my.go/app\",\n\t\t\t},\n\t\t\tResult: &endpoint.Result{\n\t\t\t\tHTTPStatus: 400,\n\t\t\t\tHostname:   \"my.go\",\n\t\t\t\tErrors:     []string{\"error 01\", \"error 02\"},\n\t\t\t\tSuccess:    false,\n\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\t\t\tSuccess:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tResolved: false,\n\t\t\twant: alertCreateRequest{\n\t\t\t\tMessage:     \"[end game] my app - \" + description,\n\t\t\t\tPriority:    \"P1\",\n\t\t\t\tSource:      \"gatus\",\n\t\t\t\tEntity:      \"gatus-end-game-my-app\",\n\t\t\t\tAlias:       \"gatus-healthcheck-end-game-my-app\",\n\t\t\t\tDescription: \"An alert for *end game/my app* has been triggered due to having failed 6 time(s) in a row\\n▢ - `[STATUS] == 200`\\n\",\n\t\t\t\tTags:        []string{\"foo\"},\n\t\t\t\tDetails: map[string]string{\n\t\t\t\t\t\"endpoint:url\":       \"https://my.go/app\",\n\t\t\t\t\t\"endpoint:group\":     \"end game\",\n\t\t\t\t\t\"result:hostname\":    \"my.go\",\n\t\t\t\t\t\"result:errors\":      \"error 01,error 02\",\n\t\t\t\t\t\"result:http_status\": \"400\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tactual := scenario\n\t\tt.Run(actual.Name, func(t *testing.T) {\n\t\t\t_ = scenario.Provider.Validate()\n\t\t\tif got := actual.Provider.buildCreateRequestBody(&scenario.Provider.DefaultConfig, actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {\n\t\t\t\tt.Errorf(\"got:\\n%v\\nwant:\\n%v\", got, actual.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildCloseRequestBody(t *testing.T) {\n\tt.Parallel()\n\tdescription := \"alert description\"\n\tscenarios := []struct {\n\t\tName     string\n\t\tProvider *AlertProvider\n\t\tAlert    *alert.Alert\n\t\tEndpoint *endpoint.Endpoint\n\t\twant     alertCloseRequest\n\t}{\n\t\t{\n\t\t\tName:     \"Missing all values\",\n\t\t\tProvider: &AlertProvider{},\n\t\t\tAlert:    &alert.Alert{},\n\t\t\tEndpoint: &endpoint.Endpoint{},\n\t\t\twant: alertCloseRequest{\n\t\t\t\tSource: \"\",\n\t\t\t\tNote:   \"RESOLVED:  - \",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"Basic values\",\n\t\t\tProvider: &AlertProvider{},\n\t\t\tAlert: &alert.Alert{\n\t\t\t\tDescription: &description,\n\t\t\t},\n\t\t\tEndpoint: &endpoint.Endpoint{\n\t\t\t\tName: \"endpoint name\",\n\t\t\t},\n\t\t\twant: alertCloseRequest{\n\t\t\t\tSource: \"endpoint-name\",\n\t\t\t\tNote:   \"RESOLVED: endpoint name - alert description\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tactual := scenario\n\t\tt.Run(actual.Name, func(t *testing.T) {\n\t\t\tif got := actual.Provider.buildCloseRequestBody(actual.Endpoint, actual.Alert); !reflect.DeepEqual(got, actual.want) {\n\t\t\t\tt.Errorf(\"buildCloseRequestBody() = %v, want %v\", got, actual.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{APIKey: \"00000000-0000-0000-0000-000000000000\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"api-key\": \"00000000-0000-0000-0000-000000000001\"}},\n\t\t\tExpectedOutput: Config{APIKey: \"00000000-0000-0000-0000-000000000001\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(\"\", &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.APIKey != scenario.ExpectedOutput.APIKey {\n\t\t\t\tt.Errorf(\"expected APIKey to be %s, got %s\", scenario.ExpectedOutput.APIKey, got.APIKey)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(\"\", &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/pagerduty/pagerduty.go",
    "content": "package pagerduty\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/logr\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\trestAPIURL = \"https://events.pagerduty.com/v2/enqueue\"\n)\n\nvar (\n\tErrIntegrationKeyNotSet   = errors.New(\"integration-key must have exactly 32 characters\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tIntegrationKey string `yaml:\"integration-key\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.IntegrationKey) != 32 {\n\t\treturn ErrIntegrationKeyNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.IntegrationKey) > 0 {\n\t\tcfg.IntegrationKey = override.IntegrationKey\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using PagerDuty\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\t// Either the default integration key has the right length, or there are overrides who are properly configured.\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\n//\n// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\tif alert.IsSendingOnResolved() {\n\t\tif resolved {\n\t\t\t// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey\n\t\t\talert.ResolveKey = \"\"\n\t\t} else {\n\t\t\t// We need to retrieve the resolve key from the response\n\t\t\tvar payload pagerDutyResponsePayload\n\t\t\tif err = json.NewDecoder(response.Body).Decode(&payload); err != nil {\n\t\t\t\t// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.\n\t\t\t\tlogr.Errorf(\"[pagerduty.Send] Ran into error decoding pagerduty response: %s\", err.Error())\n\t\t\t} else {\n\t\t\t\talert.ResolveKey = payload.DedupKey\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tRoutingKey  string  `json:\"routing_key\"`\n\tDedupKey    string  `json:\"dedup_key\"`\n\tEventAction string  `json:\"event_action\"`\n\tPayload     Payload `json:\"payload\"`\n}\n\ntype Payload struct {\n\tSummary  string `json:\"summary\"`\n\tSource   string `json:\"source\"`\n\tSeverity string `json:\"severity\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message, eventAction, resolveKey string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"RESOLVED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t\teventAction = \"resolve\"\n\t\tresolveKey = alert.ResolveKey\n\t} else {\n\t\tmessage = fmt.Sprintf(\"TRIGGERED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t\teventAction = \"trigger\"\n\t\tresolveKey = \"\"\n\t}\n\tbody, _ := json.Marshal(Body{\n\t\tRoutingKey:  cfg.IntegrationKey,\n\t\tDedupKey:    resolveKey,\n\t\tEventAction: eventAction,\n\t\tPayload: Payload{\n\t\t\tSummary:  message,\n\t\t\tSource:   \"Gatus\",\n\t\t\tSeverity: \"critical\",\n\t\t},\n\t})\n\treturn body\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n\ntype pagerDutyResponsePayload struct {\n\tStatus   string `json:\"status\"`\n\tMessage  string `json:\"message\"`\n\tDedupKey string `json:\"dedup_key\"`\n}\n"
  },
  {
    "path": "alerting/provider/pagerduty/pagerduty_test.go",
    "content": "package pagerduty\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000000\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid, got error:\", err.Error())\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000000\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000000\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000000\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000000\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tdescription := \"test\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000000\"}},\n\t\t\tAlert:        alert.Alert{Description: &description},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"routing_key\\\":\\\"00000000000000000000000000000000\\\",\\\"dedup_key\\\":\\\"\\\",\\\"event_action\\\":\\\"trigger\\\",\\\"payload\\\":{\\\"summary\\\":\\\"TRIGGERED: endpoint-name - test\\\",\\\"source\\\":\\\"Gatus\\\",\\\"severity\\\":\\\"critical\\\"}}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000000\"}},\n\t\t\tAlert:        alert.Alert{Description: &description, ResolveKey: \"key\"},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"routing_key\\\":\\\"00000000000000000000000000000000\\\",\\\"dedup_key\\\":\\\"key\\\",\\\"event_action\\\":\\\"resolve\\\",\\\"payload\\\":{\\\"summary\\\":\\\"RESOLVED: endpoint-name - test\\\",\\\"source\\\":\\\"Gatus\\\",\\\"severity\\\":\\\"critical\\\"}}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tbody := scenario.Provider.buildRequestBody(&scenario.Provider.DefaultConfig, &endpoint.Endpoint{Name: \"endpoint-name\"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{IntegrationKey: \"00000000000000000000000000000001\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{IntegrationKey: \"00000000000000000000000000000002\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"integration-key\": \"00000000000000000000000000000003\"}},\n\t\t\tExpectedOutput: Config{IntegrationKey: \"00000000000000000000000000000003\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/plivo/plivo.go",
    "content": "package plivo\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrAuthIDNotSet           = errors.New(\"auth-id not set\")\n\tErrAuthTokenNotSet        = errors.New(\"auth-token not set\")\n\tErrFromNotSet             = errors.New(\"from not set\")\n\tErrToNotSet               = errors.New(\"to not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tAuthID    string   `yaml:\"auth-id\"`\n\tAuthToken string   `yaml:\"auth-token\"`\n\tFrom      string   `yaml:\"from\"`\n\tTo        []string `yaml:\"to\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.AuthID) == 0 {\n\t\treturn ErrAuthIDNotSet\n\t}\n\tif len(cfg.AuthToken) == 0 {\n\t\treturn ErrAuthTokenNotSet\n\t}\n\tif len(cfg.From) == 0 {\n\t\treturn ErrFromNotSet\n\t}\n\tif len(cfg.To) == 0 {\n\t\treturn ErrToNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.AuthID) > 0 {\n\t\tcfg.AuthID = override.AuthID\n\t}\n\tif len(override.AuthToken) > 0 {\n\t\tcfg.AuthToken = override.AuthToken\n\t}\n\tif len(override.From) > 0 {\n\t\tcfg.From = override.From\n\t}\n\tif len(override.To) > 0 {\n\t\tcfg.To = override.To\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Plivo\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmessage := provider.buildMessage(cfg, ep, alert, result, resolved)\n\t// Send individual SMS messages to each recipient\n\tfor _, to := range cfg.To {\n\t\tif err := provider.sendSMS(cfg, to, message); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// sendSMS sends an SMS message to a single recipient\nfunc (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {\n\tpayload := map[string]string{\n\t\t\"src\":  cfg.From,\n\t\t\"dst\":  to,\n\t\t\"text\": message,\n\t}\n\tpayloadBytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest, err := http.NewRequest(http.MethodPost, fmt.Sprintf(\"https://api.plivo.com/v1/Account/%s/Message/\", cfg.AuthID), bytes.NewBuffer(payloadBytes))\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Authorization\", fmt.Sprintf(\"Basic %s\", base64.StdEncoding.EncodeToString([]byte(cfg.AuthID+\":\"+cfg.AuthToken))))\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to plivo alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\n// buildMessage builds the message for the provider\nfunc (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {\n\tif resolved {\n\t\treturn fmt.Sprintf(\"RESOLVED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t} else {\n\t\treturn fmt.Sprintf(\"TRIGGERED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t}\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/plivo/plivo_test.go",
    "content": "package plivo\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestPlivoAlertProvider_IsValid(t *testing.T) {\n\tscenarios := []struct {\n\t\tName          string\n\t\tProvider      AlertProvider\n\t\tExpectedError error\n\t}{\n\t\t{\n\t\t\tName:          \"invalid-provider-missing-config\",\n\t\t\tProvider:      AlertProvider{},\n\t\t\tExpectedError: ErrAuthIDNotSet,\n\t\t},\n\t\t{\n\t\t\tName: \"valid-provider\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAuthID:    \"1\",\n\t\t\t\t\tAuthToken: \"1\",\n\t\t\t\t\tFrom:      \"1234567890\",\n\t\t\t\t\tTo:        []string{\"0987654321\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tName: \"valid-provider-with-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAuthID:    \"1\",\n\t\t\t\t\tAuthToken: \"1\",\n\t\t\t\t\tFrom:      \"1234567890\",\n\t\t\t\t\tTo:        []string{\"0987654321\"},\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group1\",\n\t\t\t\t\t\tConfig: Config{AuthID: \"2\", AuthToken: \"2\", From: \"2222222222\", To: []string{\"3333333333\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tName: \"invalid-provider-duplicate-group-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAuthID:    \"1\",\n\t\t\t\t\tAuthToken: \"1\",\n\t\t\t\t\tFrom:      \"1234567890\",\n\t\t\t\t\tTo:        []string{\"0987654321\"},\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group1\",\n\t\t\t\t\t\tConfig: Config{AuthID: \"2\", AuthToken: \"2\", From: \"2222222222\", To: []string{\"3333333333\"}},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group1\",\n\t\t\t\t\t\tConfig: Config{AuthID: \"3\", AuthToken: \"3\", From: \"4444444444\", To: []string{\"5555555555\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedError: ErrDuplicateGroupOverride,\n\t\t},\n\t\t{\n\t\t\tName: \"invalid-provider-empty-group-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAuthID:    \"1\",\n\t\t\t\t\tAuthToken: \"1\",\n\t\t\t\t\tFrom:      \"1234567890\",\n\t\t\t\t\tTo:        []string{\"0987654321\"},\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"\",\n\t\t\t\t\t\tConfig: Config{AuthID: \"2\", AuthToken: \"2\", From: \"2222222222\", To: []string{\"3333333333\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedError: ErrDuplicateGroupOverride,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\terr := scenario.Provider.Validate()\n\t\t\tif scenario.ExpectedError == nil && err != nil {\n\t\t\t\tt.Errorf(\"expected no error, got %v\", err)\n\t\t\t}\n\t\t\tif scenario.ExpectedError != nil && err == nil {\n\t\t\t\tt.Errorf(\"expected error %v, got none\", scenario.ExpectedError)\n\t\t\t}\n\t\t\tif scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.ExpectedError, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"multiple-recipients\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\", \"1122334455\"}}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildMessage(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName            string\n\t\tProvider        AlertProvider\n\t\tAlert           alert.Alert\n\t\tResolved        bool\n\t\tExpectedMessage string\n\t}{\n\t\t{\n\t\t\tName:            \"triggered\",\n\t\t\tProvider:        AlertProvider{DefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}}},\n\t\t\tAlert:           alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        false,\n\t\t\tExpectedMessage: \"TRIGGERED: endpoint-name - description-1\",\n\t\t},\n\t\t{\n\t\t\tName:            \"resolved\",\n\t\t\tProvider:        AlertProvider{DefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}}},\n\t\t\tAlert:           alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        true,\n\t\t\tExpectedMessage: \"RESOLVED: endpoint-name - description-2\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tmessage := scenario.Provider.buildMessage(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif message != scenario.ExpectedMessage {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.ExpectedMessage, message)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_sendSMS(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tcfg := &Config{\n\t\tAuthID:    \"test-auth-id\",\n\t\tAuthToken: \"test-auth-token\",\n\t\tFrom:      \"1234567890\",\n\t}\n\tscenarios := []struct {\n\t\tName             string\n\t\tTo               string\n\t\tMessage          string\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:    \"successful-sms\",\n\t\t\tTo:      \"0987654321\",\n\t\t\tMessage: \"Test message\",\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\t// Verify request structure\n\t\t\t\tbody, _ := io.ReadAll(r.Body)\n\t\t\t\tvar payload map[string]string\n\t\t\t\tjson.Unmarshal(body, &payload)\n\t\t\t\tif payload[\"src\"] != cfg.From {\n\t\t\t\t\tt.Errorf(\"expected src %s, got %s\", cfg.From, payload[\"src\"])\n\t\t\t\t}\n\t\t\t\tif payload[\"dst\"] != \"0987654321\" {\n\t\t\t\t\tt.Errorf(\"expected dst %s, got %s\", \"0987654321\", payload[\"dst\"])\n\t\t\t\t}\n\t\t\t\tif payload[\"text\"] != \"Test message\" {\n\t\t\t\t\tt.Errorf(\"expected text %s, got %s\", \"Test message\", payload[\"text\"])\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:    \"failed-sms\",\n\t\t\tTo:      \"0987654321\",\n\t\t\tMessage: \"Test message\",\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\tprovider := AlertProvider{}\n\t\t\terr := provider.sendSMS(cfg, scenario.To, scenario.Message)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group1\",\n\t\t\t\t\t\tConfig: Config{AuthID: \"3\", AuthToken: \"4\", From: \"3333333333\", To: []string{\"7777777777\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group1\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{AuthID: \"3\", AuthToken: \"4\", From: \"3333333333\", To: []string{\"7777777777\"}},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-no-match\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group1\",\n\t\t\t\t\t\tConfig: Config{AuthID: \"3\", AuthToken: \"4\", From: \"3333333333\", To: []string{\"7777777777\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group2\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"auth-id\": \"5\", \"auth-token\": \"6\", \"from\": \"5555555555\", \"to\": []string{\"9999999999\"}}},\n\t\t\tExpectedOutput: Config{AuthID: \"5\", AuthToken: \"6\", From: \"5555555555\", To: []string{\"9999999999\"}},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-and-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{AuthID: \"1\", AuthToken: \"2\", From: \"1234567890\", To: []string{\"0987654321\"}},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group1\",\n\t\t\t\t\t\tConfig: Config{AuthID: \"3\", AuthToken: \"4\", From: \"3333333333\", To: []string{\"7777777777\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group1\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"auth-id\": \"5\", \"auth-token\": \"6\"}},\n\t\t\tExpectedOutput: Config{AuthID: \"5\", AuthToken: \"6\", From: \"3333333333\", To: []string{\"7777777777\"}},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected no error, got:\", err.Error())\n\t\t\t}\n\t\t\tif got.AuthID != scenario.ExpectedOutput.AuthID {\n\t\t\t\tt.Errorf(\"expected AuthID to be %s, got %s\", scenario.ExpectedOutput.AuthID, got.AuthID)\n\t\t\t}\n\t\t\tif got.AuthToken != scenario.ExpectedOutput.AuthToken {\n\t\t\t\tt.Errorf(\"expected AuthToken to be %s, got %s\", scenario.ExpectedOutput.AuthToken, got.AuthToken)\n\t\t\t}\n\t\t\tif got.From != scenario.ExpectedOutput.From {\n\t\t\t\tt.Errorf(\"expected From to be %s, got %s\", scenario.ExpectedOutput.From, got.From)\n\t\t\t}\n\t\t\tif len(got.To) != len(scenario.ExpectedOutput.To) {\n\t\t\t\tt.Errorf(\"expected To length to be %d, got %d\", len(scenario.ExpectedOutput.To), len(got.To))\n\t\t\t}\n\t\t\tfor i, to := range got.To {\n\t\t\t\tif to != scenario.ExpectedOutput.To[i] {\n\t\t\t\t\tt.Errorf(\"expected To[%d] to be %s, got %s\", i, scenario.ExpectedOutput.To[i], to)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tName          string\n\t\tConfig        Config\n\t\tExpectedError error\n\t}{\n\t\t{\n\t\t\tName: \"valid-config\",\n\t\t\tConfig: Config{\n\t\t\t\tAuthID:    \"test-auth-id\",\n\t\t\t\tAuthToken: \"test-auth-token\",\n\t\t\t\tFrom:      \"1234567890\",\n\t\t\t\tTo:        []string{\"0987654321\"},\n\t\t\t},\n\t\t\tExpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tName: \"missing-auth-id\",\n\t\t\tConfig: Config{\n\t\t\t\tAuthToken: \"test-auth-token\",\n\t\t\t\tFrom:      \"1234567890\",\n\t\t\t\tTo:        []string{\"0987654321\"},\n\t\t\t},\n\t\t\tExpectedError: ErrAuthIDNotSet,\n\t\t},\n\t\t{\n\t\t\tName: \"missing-auth-token\",\n\t\t\tConfig: Config{\n\t\t\t\tAuthID: \"test-auth-id\",\n\t\t\t\tFrom:   \"1234567890\",\n\t\t\t\tTo:     []string{\"0987654321\"},\n\t\t\t},\n\t\t\tExpectedError: ErrAuthTokenNotSet,\n\t\t},\n\t\t{\n\t\t\tName: \"missing-from\",\n\t\t\tConfig: Config{\n\t\t\t\tAuthID:    \"test-auth-id\",\n\t\t\t\tAuthToken: \"test-auth-token\",\n\t\t\t\tTo:        []string{\"0987654321\"},\n\t\t\t},\n\t\t\tExpectedError: ErrFromNotSet,\n\t\t},\n\t\t{\n\t\t\tName: \"missing-to\",\n\t\t\tConfig: Config{\n\t\t\t\tAuthID:    \"test-auth-id\",\n\t\t\t\tAuthToken: \"test-auth-token\",\n\t\t\t\tFrom:      \"1234567890\",\n\t\t\t},\n\t\t\tExpectedError: ErrToNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\terr := scenario.Config.Validate()\n\t\t\tif scenario.ExpectedError == nil && err != nil {\n\t\t\t\tt.Errorf(\"expected no error, got %v\", err)\n\t\t\t}\n\t\t\tif scenario.ExpectedError != nil && err == nil {\n\t\t\t\tt.Errorf(\"expected error %v, got none\", scenario.ExpectedError)\n\t\t\t}\n\t\t\tif scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.ExpectedError, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_Merge(t *testing.T) {\n\tcfg := Config{\n\t\tAuthID:    \"original-auth-id\",\n\t\tAuthToken: \"original-auth-token\",\n\t\tFrom:      \"1111111111\",\n\t\tTo:        []string{\"2222222222\"},\n\t}\n\toverride := Config{\n\t\tAuthID:    \"override-auth-id\",\n\t\tAuthToken: \"override-auth-token\",\n\t\tFrom:      \"3333333333\",\n\t\tTo:        []string{\"4444444444\", \"5555555555\"},\n\t}\n\tcfg.Merge(&override)\n\tif cfg.AuthID != \"override-auth-id\" {\n\t\tt.Errorf(\"expected AuthID to be %s, got %s\", \"override-auth-id\", cfg.AuthID)\n\t}\n\tif cfg.AuthToken != \"override-auth-token\" {\n\t\tt.Errorf(\"expected AuthToken to be %s, got %s\", \"override-auth-token\", cfg.AuthToken)\n\t}\n\tif cfg.From != \"3333333333\" {\n\t\tt.Errorf(\"expected From to be %s, got %s\", \"3333333333\", cfg.From)\n\t}\n\tif len(cfg.To) != 2 || cfg.To[0] != \"4444444444\" || cfg.To[1] != \"5555555555\" {\n\t\tt.Errorf(\"expected To to be [4444444444, 5555555555], got %v\", cfg.To)\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/provider.go",
    "content": "package provider\n\nimport (\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/awsses\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/clickup\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/custom\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/datadog\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/discord\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/email\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/gitea\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/github\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/gitlab\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/googlechat\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/gotify\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/homeassistant\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ifttt\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ilert\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/incidentio\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/line\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/matrix\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/mattermost\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/messagebird\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/n8n\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/newrelic\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ntfy\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/opsgenie\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/pagerduty\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/plivo\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/pushover\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/rocketchat\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/sendgrid\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/signal\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/signl4\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/slack\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/splunk\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/squadcast\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/teams\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/telegram\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/twilio\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/webex\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/zapier\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/zulip\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\n// AlertProvider is the interface that each provider should implement\ntype AlertProvider interface {\n\t// Validate the provider's configuration\n\tValidate() error\n\n\t// Send an alert using the provider\n\tSend(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error\n\n\t// GetDefaultAlert returns the provider's default alert configuration\n\tGetDefaultAlert() *alert.Alert\n\n\t// ValidateOverrides validates the alert's provider override and, if present, the group override\n\tValidateOverrides(group string, alert *alert.Alert) error\n}\n\ntype Config[T any] interface {\n\tValidate() error\n\tMerge(override *T)\n}\n\n// MergeProviderDefaultAlertIntoEndpointAlert parses an Endpoint alert by using the provider's default alert as a baseline\nfunc MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAlert *alert.Alert) {\n\tif providerDefaultAlert == nil || endpointAlert == nil {\n\t\treturn\n\t}\n\tif endpointAlert.Enabled == nil {\n\t\tendpointAlert.Enabled = providerDefaultAlert.Enabled\n\t}\n\tif endpointAlert.SendOnResolved == nil {\n\t\tendpointAlert.SendOnResolved = providerDefaultAlert.SendOnResolved\n\t}\n\tif endpointAlert.Description == nil {\n\t\tendpointAlert.Description = providerDefaultAlert.Description\n\t}\n\tif endpointAlert.FailureThreshold == 0 {\n\t\tendpointAlert.FailureThreshold = providerDefaultAlert.FailureThreshold\n\t}\n\tif endpointAlert.SuccessThreshold == 0 {\n\t\tendpointAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold\n\t}\n\tif endpointAlert.MinimumReminderInterval == 0 {\n\t\tendpointAlert.MinimumReminderInterval = providerDefaultAlert.MinimumReminderInterval\n\t}\n}\n\nvar (\n\t// Validate provider interface implementation on compile\n\t_ AlertProvider = (*awsses.AlertProvider)(nil)\n\t_ AlertProvider = (*clickup.AlertProvider)(nil)\n\t_ AlertProvider = (*custom.AlertProvider)(nil)\n\t_ AlertProvider = (*datadog.AlertProvider)(nil)\n\t_ AlertProvider = (*discord.AlertProvider)(nil)\n\t_ AlertProvider = (*email.AlertProvider)(nil)\n\t_ AlertProvider = (*gitea.AlertProvider)(nil)\n\t_ AlertProvider = (*github.AlertProvider)(nil)\n\t_ AlertProvider = (*gitlab.AlertProvider)(nil)\n\t_ AlertProvider = (*googlechat.AlertProvider)(nil)\n\t_ AlertProvider = (*gotify.AlertProvider)(nil)\n\t_ AlertProvider = (*homeassistant.AlertProvider)(nil)\n\t_ AlertProvider = (*ifttt.AlertProvider)(nil)\n\t_ AlertProvider = (*ilert.AlertProvider)(nil)\n\t_ AlertProvider = (*incidentio.AlertProvider)(nil)\n\t_ AlertProvider = (*line.AlertProvider)(nil)\n\t_ AlertProvider = (*matrix.AlertProvider)(nil)\n\t_ AlertProvider = (*mattermost.AlertProvider)(nil)\n\t_ AlertProvider = (*messagebird.AlertProvider)(nil)\n\t_ AlertProvider = (*n8n.AlertProvider)(nil)\n\t_ AlertProvider = (*newrelic.AlertProvider)(nil)\n\t_ AlertProvider = (*ntfy.AlertProvider)(nil)\n\t_ AlertProvider = (*opsgenie.AlertProvider)(nil)\n\t_ AlertProvider = (*pagerduty.AlertProvider)(nil)\n\t_ AlertProvider = (*plivo.AlertProvider)(nil)\n\t_ AlertProvider = (*pushover.AlertProvider)(nil)\n\t_ AlertProvider = (*rocketchat.AlertProvider)(nil)\n\t_ AlertProvider = (*sendgrid.AlertProvider)(nil)\n\t_ AlertProvider = (*signal.AlertProvider)(nil)\n\t_ AlertProvider = (*signl4.AlertProvider)(nil)\n\t_ AlertProvider = (*slack.AlertProvider)(nil)\n\t_ AlertProvider = (*splunk.AlertProvider)(nil)\n\t_ AlertProvider = (*squadcast.AlertProvider)(nil)\n\t_ AlertProvider = (*teams.AlertProvider)(nil)\n\t_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)\n\t_ AlertProvider = (*telegram.AlertProvider)(nil)\n\t_ AlertProvider = (*twilio.AlertProvider)(nil)\n\t_ AlertProvider = (*webex.AlertProvider)(nil)\n\t_ AlertProvider = (*zapier.AlertProvider)(nil)\n\t_ AlertProvider = (*zulip.AlertProvider)(nil)\n\n\t// Validate config interface implementation on compile\n\t_ Config[awsses.Config]         = (*awsses.Config)(nil)\n\t_ Config[clickup.Config]        = (*clickup.Config)(nil)\n\t_ Config[custom.Config]         = (*custom.Config)(nil)\n\t_ Config[datadog.Config]        = (*datadog.Config)(nil)\n\t_ Config[discord.Config]        = (*discord.Config)(nil)\n\t_ Config[email.Config]          = (*email.Config)(nil)\n\t_ Config[gitea.Config]          = (*gitea.Config)(nil)\n\t_ Config[github.Config]         = (*github.Config)(nil)\n\t_ Config[gitlab.Config]         = (*gitlab.Config)(nil)\n\t_ Config[googlechat.Config]     = (*googlechat.Config)(nil)\n\t_ Config[gotify.Config]         = (*gotify.Config)(nil)\n\t_ Config[homeassistant.Config]  = (*homeassistant.Config)(nil)\n\t_ Config[ifttt.Config]          = (*ifttt.Config)(nil)\n\t_ Config[ilert.Config]          = (*ilert.Config)(nil)\n\t_ Config[incidentio.Config]     = (*incidentio.Config)(nil)\n\t_ Config[line.Config]           = (*line.Config)(nil)\n\t_ Config[matrix.Config]         = (*matrix.Config)(nil)\n\t_ Config[mattermost.Config]     = (*mattermost.Config)(nil)\n\t_ Config[messagebird.Config]    = (*messagebird.Config)(nil)\n\t_ Config[n8n.Config]            = (*n8n.Config)(nil)\n\t_ Config[newrelic.Config]       = (*newrelic.Config)(nil)\n\t_ Config[ntfy.Config]           = (*ntfy.Config)(nil)\n\t_ Config[opsgenie.Config]       = (*opsgenie.Config)(nil)\n\t_ Config[pagerduty.Config]      = (*pagerduty.Config)(nil)\n\t_ Config[plivo.Config]          = (*plivo.Config)(nil)\n\t_ Config[pushover.Config]       = (*pushover.Config)(nil)\n\t_ Config[rocketchat.Config]     = (*rocketchat.Config)(nil)\n\t_ Config[sendgrid.Config]       = (*sendgrid.Config)(nil)\n\t_ Config[signal.Config]         = (*signal.Config)(nil)\n\t_ Config[signl4.Config]         = (*signl4.Config)(nil)\n\t_ Config[slack.Config]          = (*slack.Config)(nil)\n\t_ Config[splunk.Config]         = (*splunk.Config)(nil)\n\t_ Config[squadcast.Config]      = (*squadcast.Config)(nil)\n\t_ Config[teams.Config]          = (*teams.Config)(nil)\n\t_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)\n\t_ Config[telegram.Config]       = (*telegram.Config)(nil)\n\t_ Config[twilio.Config]         = (*twilio.Config)(nil)\n\t_ Config[webex.Config]          = (*webex.Config)(nil)\n\t_ Config[zapier.Config]         = (*zapier.Config)(nil)\n\t_ Config[zulip.Config]          = (*zulip.Config)(nil)\n)\n"
  },
  {
    "path": "alerting/provider/provider_test.go",
    "content": "package provider\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n)\n\nfunc TestParseWithDefaultAlert(t *testing.T) {\n\ttype Scenario struct {\n\t\tName                                             string\n\t\tDefaultAlert, EndpointAlert, ExpectedOutputAlert *alert.Alert\n\t}\n\tenabled := true\n\tdisabled := false\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName: \"endpoint-alert-type-only\",\n\t\t\tDefaultAlert: &alert.Alert{\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tDescription:      &firstDescription,\n\t\t\t\tFailureThreshold: 5,\n\t\t\t\tSuccessThreshold: 10,\n\t\t\t\tMinimumReminderInterval: 30 * time.Second,\n\t\t\t},\n\t\t\tEndpointAlert: &alert.Alert{\n\t\t\t\tType: alert.TypeDiscord,\n\t\t\t},\n\t\t\tExpectedOutputAlert: &alert.Alert{\n\t\t\t\tType:             alert.TypeDiscord,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tDescription:      &firstDescription,\n\t\t\t\tFailureThreshold: 5,\n\t\t\t\tSuccessThreshold: 10,\n\t\t\t\tMinimumReminderInterval: 30 * time.Second,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"endpoint-alert-overwrites-default-alert\",\n\t\t\tDefaultAlert: &alert.Alert{\n\t\t\t\tEnabled:          &disabled,\n\t\t\t\tSendOnResolved:   &disabled,\n\t\t\t\tDescription:      &firstDescription,\n\t\t\t\tFailureThreshold: 5,\n\t\t\t\tSuccessThreshold: 10,\n\t\t\t},\n\t\t\tEndpointAlert: &alert.Alert{\n\t\t\t\tType:             alert.TypeTelegram,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tDescription:      &secondDescription,\n\t\t\t\tFailureThreshold: 6,\n\t\t\t\tSuccessThreshold: 11,\n\t\t\t},\n\t\t\tExpectedOutputAlert: &alert.Alert{\n\t\t\t\tType:             alert.TypeTelegram,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tDescription:      &secondDescription,\n\t\t\t\tFailureThreshold: 6,\n\t\t\t\tSuccessThreshold: 11,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"endpoint-alert-partially-overwrites-default-alert\",\n\t\t\tDefaultAlert: &alert.Alert{\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tDescription:      &firstDescription,\n\t\t\t\tFailureThreshold: 5,\n\t\t\t\tSuccessThreshold: 10,\n\t\t\t},\n\t\t\tEndpointAlert: &alert.Alert{\n\t\t\t\tType:             alert.TypeDiscord,\n\t\t\t\tEnabled:          nil,\n\t\t\t\tSendOnResolved:   nil,\n\t\t\t\tFailureThreshold: 6,\n\t\t\t\tSuccessThreshold: 11,\n\t\t\t},\n\t\t\tExpectedOutputAlert: &alert.Alert{\n\t\t\t\tType:             alert.TypeDiscord,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tDescription:      &firstDescription,\n\t\t\t\tFailureThreshold: 6,\n\t\t\t\tSuccessThreshold: 11,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"default-alert-type-should-be-ignored\",\n\t\t\tDefaultAlert: &alert.Alert{\n\t\t\t\tType:             alert.TypeTelegram,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tDescription:      &firstDescription,\n\t\t\t\tFailureThreshold: 5,\n\t\t\t\tSuccessThreshold: 10,\n\t\t\t},\n\t\t\tEndpointAlert: &alert.Alert{\n\t\t\t\tType: alert.TypeDiscord,\n\t\t\t},\n\t\t\tExpectedOutputAlert: &alert.Alert{\n\t\t\t\tType:             alert.TypeDiscord,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tDescription:      &firstDescription,\n\t\t\t\tFailureThreshold: 5,\n\t\t\t\tSuccessThreshold: 10,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"no-default-alert\",\n\t\t\tDefaultAlert: &alert.Alert{\n\t\t\t\tType:             alert.TypeDiscord,\n\t\t\t\tEnabled:          nil,\n\t\t\t\tSendOnResolved:   nil,\n\t\t\t\tDescription:      &firstDescription,\n\t\t\t\tFailureThreshold: 2,\n\t\t\t\tSuccessThreshold: 5,\n\t\t\t},\n\t\t\tEndpointAlert:       nil,\n\t\t\tExpectedOutputAlert: nil,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tMergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)\n\t\t\tif scenario.ExpectedOutputAlert == nil {\n\t\t\t\tif scenario.EndpointAlert != nil {\n\t\t\t\t\tt.Fail()\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif scenario.EndpointAlert.IsEnabled() != scenario.ExpectedOutputAlert.IsEnabled() {\n\t\t\t\tt.Errorf(\"expected EndpointAlert.IsEnabled() to be %v, got %v\", scenario.ExpectedOutputAlert.IsEnabled(), scenario.EndpointAlert.IsEnabled())\n\t\t\t}\n\t\t\tif scenario.EndpointAlert.IsSendingOnResolved() != scenario.ExpectedOutputAlert.IsSendingOnResolved() {\n\t\t\t\tt.Errorf(\"expected EndpointAlert.IsSendingOnResolved() to be %v, got %v\", scenario.ExpectedOutputAlert.IsSendingOnResolved(), scenario.EndpointAlert.IsSendingOnResolved())\n\t\t\t}\n\t\t\tif scenario.EndpointAlert.GetDescription() != scenario.ExpectedOutputAlert.GetDescription() {\n\t\t\t\tt.Errorf(\"expected EndpointAlert.GetDescription() to be %v, got %v\", scenario.ExpectedOutputAlert.GetDescription(), scenario.EndpointAlert.GetDescription())\n\t\t\t}\n\t\t\tif scenario.EndpointAlert.FailureThreshold != scenario.ExpectedOutputAlert.FailureThreshold {\n\t\t\t\tt.Errorf(\"expected EndpointAlert.FailureThreshold to be %v, got %v\", scenario.ExpectedOutputAlert.FailureThreshold, scenario.EndpointAlert.FailureThreshold)\n\t\t\t}\n\t\t\tif scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {\n\t\t\t\tt.Errorf(\"expected EndpointAlert.SuccessThreshold to be %v, got %v\", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)\n\t\t\t}\n\t\t\tif int(scenario.EndpointAlert.MinimumReminderInterval) != int(scenario.ExpectedOutputAlert.MinimumReminderInterval) {\n\t\t\t\tt.Errorf(\"expected EndpointAlert.MinimumReminderInterval to be %v, got %v\", scenario.ExpectedOutputAlert.MinimumReminderInterval, scenario.EndpointAlert.MinimumReminderInterval)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/pushover/pushover.go",
    "content": "package pushover\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\tApiURL          = \"https://api.pushover.net/1/messages.json\"\n\tdefaultPriority = 0\n)\n\nvar (\n\tErrInvalidApplicationToken = errors.New(\"application-token must be 30 characters long\")\n\tErrInvalidUserKey          = errors.New(\"user-key must be 30 characters long\")\n\tErrInvalidPriority         = errors.New(\"priority and resolved-priority must be between -2 and 2\")\n\tErrInvalidDevice           = errors.New(\"device name must have 25 characters or less\")\n)\n\ntype Config struct {\n\t// Key used to authenticate the application sending\n\t// See \"Your Applications\" on the dashboard, or add a new one: https://pushover.net/apps/build\n\tApplicationToken string `yaml:\"application-token\"`\n\n\t// Key of the user or group the messages should be sent to\n\tUserKey string `yaml:\"user-key\"`\n\n\t// The title of your message\n\t// default: \"Gatus: <endpoint>\"\"\n\tTitle string `yaml:\"title,omitempty\"`\n\n\t// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)\n\t// default: 0\n\tPriority int `yaml:\"priority,omitempty\"`\n\n\t// Priority of resolved messages, ranging from -2 (very low) to 2 (Emergency)\n\t// default: 0\n\tResolvedPriority int `yaml:\"resolved-priority,omitempty\"`\n\n\t// Sound of the messages (see: https://pushover.net/api#sounds)\n\t// default: \"\" (pushover)\n\tSound string `yaml:\"sound,omitempty\"`\n\n\t// TTL of your message (https://pushover.net/api#ttl)\n\t// If priority is 2 then this parameter is ignored\n\t// default: 0\n\tTTL int `yaml:\"ttl,omitempty\"`\n\n\t// Device to send the message to (see: https://pushover.net/api#devices)\n\t// default: \"\" (all devices)\n\tDevice string `yaml:\"device,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif cfg.Priority == 0 {\n\t\tcfg.Priority = defaultPriority\n\t}\n\tif cfg.ResolvedPriority == 0 {\n\t\tcfg.ResolvedPriority = defaultPriority\n\t}\n\tif len(cfg.ApplicationToken) != 30 {\n\t\treturn ErrInvalidApplicationToken\n\t}\n\tif len(cfg.UserKey) != 30 {\n\t\treturn ErrInvalidUserKey\n\t}\n\tif cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {\n\t\treturn ErrInvalidPriority\n\t}\n\tif len(cfg.Device) > 25 {\n\t\treturn ErrInvalidDevice\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.ApplicationToken) > 0 {\n\t\tcfg.ApplicationToken = override.ApplicationToken\n\t}\n\tif len(override.UserKey) > 0 {\n\t\tcfg.UserKey = override.UserKey\n\t}\n\tif len(override.Title) > 0 {\n\t\tcfg.Title = override.Title\n\t}\n\tif override.Priority != 0 {\n\t\tcfg.Priority = override.Priority\n\t}\n\tif override.ResolvedPriority != 0 {\n\t\tcfg.ResolvedPriority = override.ResolvedPriority\n\t}\n\tif len(override.Sound) > 0 {\n\t\tcfg.Sound = override.Sound\n\t}\n\tif override.TTL > 0 {\n\t\tcfg.TTL = override.TTL\n\t}\n\tif len(override.Device) > 0 {\n\t\tcfg.Device = override.Device\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Pushover\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\n// Reference doc for pushover: https://pushover.net/api\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, ApiURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tToken    string `json:\"token\"`\n\tUser     string `json:\"user\"`\n\tTitle    string `json:\"title,omitempty\"`\n\tMessage  string `json:\"message\"`\n\tPriority int    `json:\"priority\"`\n\tHtml     int    `json:\"html\"`\n\tSound    string `json:\"sound,omitempty\"`\n\tTTL      int    `json:\"ttl,omitempty\"`\n\tDevice   string `json:\"device,omitempty\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message, formattedConditionResults string\n\tpriority := cfg.Priority\n\tif resolved {\n\t\tpriority = cfg.ResolvedPriority\n\t\tmessage = fmt.Sprintf(\"An alert for <b>%s</b> has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for <b>%s</b> has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \"✅\"\n\t\t} else {\n\t\t\tprefix = \"❌\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"\\n%s - %s\", prefix, conditionResult.Condition)\n\t}\n\tif len(alert.GetDescription()) > 0 {\n\t\tmessage += \" with the following description: \" + alert.GetDescription()\n\t}\n\tmessage += formattedConditionResults\n\ttitle := \"Gatus: \" + ep.DisplayName()\n\tif cfg.Title != \"\" {\n\t\ttitle = cfg.Title\n\t}\n\tbody, _ := json.Marshal(Body{\n\t\tToken:    cfg.ApplicationToken,\n\t\tUser:     cfg.UserKey,\n\t\tTitle:    title,\n\t\tMessage:  message,\n\t\tPriority: priority,\n\t\tHtml:     1,\n\t\tSound:    cfg.Sound,\n\t\tTTL:      cfg.TTL,\n\t\tDevice:   cfg.Device,\n\t})\n\treturn body\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/pushover/pushover_test.go",
    "content": "package pushover\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestPushoverAlertProvider_IsValid(t *testing.T) {\n\tt.Run(\"empty-invalid-provider\", func(t *testing.T) {\n\t\tinvalidProvider := AlertProvider{}\n\t\tif err := invalidProvider.Validate(); err == nil {\n\t\t\tt.Error(\"provider shouldn't have been valid\")\n\t\t}\n\t})\n\tt.Run(\"valid-provider\", func(t *testing.T) {\n\t\tvalidProvider := AlertProvider{\n\t\t\tDefaultConfig: Config{\n\t\t\t\tApplicationToken: \"aTokenWithLengthOf30characters\",\n\t\t\t\tUserKey:          \"aTokenWithLengthOf30characters\",\n\t\t\t\tTitle:            \"Gatus Notification\",\n\t\t\t\tPriority:         1,\n\t\t\t\tResolvedPriority: 1,\n\t\t\t},\n\t\t}\n\t\tif err := validProvider.Validate(); err != nil {\n\t\t\tt.Error(\"provider should've been valid\")\n\t\t}\n\t})\n\tt.Run(\"invalid-provider\", func(t *testing.T) {\n\t\tinvalidProvider := AlertProvider{\n\t\t\tDefaultConfig: Config{\n\t\t\t\tApplicationToken: \"aTokenWithLengthOfMoreThan30characters\",\n\t\t\t\tUserKey:          \"aTokenWithLengthOfMoreThan30characters\",\n\t\t\t\tPriority:         5,\n\t\t\t},\n\t\t}\n\t\tif err := invalidProvider.Validate(); err == nil {\n\t\t\tt.Error(\"provider should've been invalid\")\n\t\t}\n\t})\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ApplicationToken: \"aTokenWithLengthOf30characters\", UserKey: \"aTokenWithLengthOf30characters\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ApplicationToken: \"aTokenWithLengthOf30characters\", UserKey: \"aTokenWithLengthOf30characters\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ApplicationToken: \"aTokenWithLengthOf30characters\", UserKey: \"aTokenWithLengthOf30characters\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ApplicationToken: \"aTokenWithLengthOf30characters\", UserKey: \"aTokenWithLengthOf30characters\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tResolvedPriority bool\n\t\tExpectedBody     string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ApplicationToken: \"TokenWithLengthOf30Characters1\", UserKey: \"TokenWithLengthOf30Characters4\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-customtitle\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ApplicationToken: \"TokenWithLengthOf30Characters1\", UserKey: \"TokenWithLengthOf30Characters4\", Title: \"Gatus Notifications\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ApplicationToken: \"TokenWithLengthOf30Characters2\", UserKey: \"TokenWithLengthOf30Characters5\", Priority: 2, ResolvedPriority: 2}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-priority\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ApplicationToken: \"TokenWithLengthOf30Characters2\", UserKey: \"TokenWithLengthOf30Characters5\", Title: \"Gatus Notifications\", Priority: 2, ResolvedPriority: 0}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"with-sound\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ApplicationToken: \"TokenWithLengthOf30Characters2\", UserKey: \"TokenWithLengthOf30Characters5\", Title: \"Gatus Notifications\", Priority: 2, ResolvedPriority: 2, Sound: \"falling\"}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"with-ttl\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ApplicationToken: \"TokenWithLengthOf30Characters2\", UserKey: \"TokenWithLengthOf30Characters5\", Title: \"Gatus Notifications\", Priority: 2, ResolvedPriority: 2, TTL: 3600}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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}\",\n\t\t},\n        {\n            Name:       \"with-device\",\n            Provider:   AlertProvider{DefaultConfig: Config{ApplicationToken: \"TokenWithLengthOf30Characters2\", UserKey: \"TokenWithLengthOf30Characters5\", Title: \"Gatus Notifications\", Priority: 2, ResolvedPriority: 2, TTL: 3600, Device: \"iphone15pro\",}},\n            Alert:      alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n            Resolved:   true,\n            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\\\"}\",\n        },\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ApplicationToken: \"aTokenWithLengthOf30characters\", UserKey: \"aTokenWithLengthOf30characters\"},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{ApplicationToken: \"aTokenWithLengthOf30characters\", UserKey: \"aTokenWithLengthOf30characters\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{ApplicationToken: \"aTokenWithLengthOf30characters\", UserKey: \"aTokenWithLengthOf30characters\"},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"application-token\": \"TokenWithLengthOf30Characters2\", \"user-key\": \"TokenWithLengthOf30Characters3\"}},\n\t\t\tExpectedOutput: Config{ApplicationToken: \"TokenWithLengthOf30Characters2\", UserKey: \"TokenWithLengthOf30Characters3\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.ApplicationToken != scenario.ExpectedOutput.ApplicationToken {\n\t\t\t\tt.Errorf(\"expected application token to be %s, got %s\", scenario.ExpectedOutput.ApplicationToken, got.ApplicationToken)\n\t\t\t}\n\t\t\tif got.UserKey != scenario.ExpectedOutput.UserKey {\n\t\t\t\tt.Errorf(\"expected user key to be %s, got %s\", scenario.ExpectedOutput.UserKey, got.UserKey)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/rocketchat/rocketchat.go",
    "content": "package rocketchat\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL string `yaml:\"webhook-url\"`       // Rocket.Chat incoming webhook URL\n\tChannel    string `yaml:\"channel,omitempty\"` // Optional channel override\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n\tif len(override.Channel) > 0 {\n\t\tcfg.Channel = override.Channel\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Rocket.Chat\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbody, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(body)\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to rocketchat alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tText        string       `json:\"text\"`\n\tChannel     string       `json:\"channel,omitempty\"`\n\tUsername    string       `json:\"username\"`\n\tAttachments []Attachment `json:\"attachments\"`\n}\n\ntype Attachment struct {\n\tTitle      string  `json:\"title\"`\n\tText       string  `json:\"text\"`\n\tColor      string  `json:\"color\"`\n\tFields     []Field `json:\"fields,omitempty\"`\n\tAuthorName string  `json:\"author_name\"`\n\tAuthorIcon string  `json:\"author_icon\"`\n}\n\ntype Field struct {\n\tTitle string `json:\"title\"`\n\tValue string `json:\"value\"`\n\tShort bool   `json:\"short\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {\n\tvar message, color string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tcolor = \"#36a64f\"\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t\tcolor = \"#dd0000\"\n\t}\n\tvar formattedConditionResults string\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \"✅\"\n\t\t} else {\n\t\t\tprefix = \"❌\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"%s - `%s`\\n\", prefix, conditionResult.Condition)\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \":\\n> \" + alertDescription\n\t}\n\tbody := Body{\n\t\tText:     \"\",\n\t\tUsername: \"Gatus\",\n\t\tAttachments: []Attachment{\n\t\t\t{\n\t\t\t\tTitle:      \"🚨 Gatus Alert\",\n\t\t\t\tText:       message + description,\n\t\t\t\tColor:      color,\n\t\t\t\tAuthorName: \"Gatus\",\n\t\t\t\tAuthorIcon: \"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\n\t\t\t},\n\t\t},\n\t}\n\tif cfg.Channel != \"\" {\n\t\tbody.Channel = cfg.Channel\n\t}\n\tif len(formattedConditionResults) > 0 {\n\t\tbody.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{\n\t\t\tTitle: \"Condition results\",\n\t\t\tValue: formattedConditionResults,\n\t\t\tShort: false,\n\t\t})\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/rocketchat/rocketchat_test.go",
    "content": "package rocketchat\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://rocketchat.com/hooks/123/abc\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid-with-channel\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://rocketchat.com/hooks/123/abc\", Channel: \"#alerts\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-webhook-url\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{}},\n\t\t\texpected: ErrWebhookURLNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://rocketchat.com/hooks/123/abc\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"username\"] != \"Gatus\" {\n\t\t\t\t\tt.Errorf(\"expected username to be 'Gatus', got %v\", body[\"username\"])\n\t\t\t\t}\n\t\t\t\tattachments := body[\"attachments\"].([]interface{})\n\t\t\t\tif len(attachments) != 1 {\n\t\t\t\t\tt.Errorf(\"expected 1 attachment, got %d\", len(attachments))\n\t\t\t\t}\n\t\t\t\tattachment := attachments[0].(map[string]interface{})\n\t\t\t\tif attachment[\"color\"] != \"#dd0000\" {\n\t\t\t\t\tt.Errorf(\"expected color to be '#dd0000', got %v\", attachment[\"color\"])\n\t\t\t\t}\n\t\t\t\ttext := attachment[\"text\"].(string)\n\t\t\t\tif !strings.Contains(text, \"failed 3 time(s)\") {\n\t\t\t\t\tt.Errorf(\"expected text to contain failure count, got %s\", text)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"triggered-with-channel\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://rocketchat.com/hooks/123/abc\", Channel: \"#alerts\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"channel\"] != \"#alerts\" {\n\t\t\t\t\tt.Errorf(\"expected channel to be '#alerts', got %v\", body[\"channel\"])\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://rocketchat.com/hooks/123/abc\"}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tattachments := body[\"attachments\"].([]interface{})\n\t\t\t\tattachment := attachments[0].(map[string]interface{})\n\t\t\t\tif attachment[\"color\"] != \"#36a64f\" {\n\t\t\t\t\tt.Errorf(\"expected color to be '#36a64f', got %v\", attachment[\"color\"])\n\t\t\t\t}\n\t\t\t\ttext := attachment[\"text\"].(string)\n\t\t\t\tif !strings.Contains(text, \"resolved\") {\n\t\t\t\t\tt.Errorf(\"expected text to contain 'resolved', got %s\", text)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://rocketchat.com/hooks/123/abc\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/sendgrid/sendgrid.go",
    "content": "package sendgrid\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\tApiURL = \"https://api.sendgrid.com/v3/mail/send\"\n)\n\nvar (\n\tErrAPIKeyNotSet           = errors.New(\"api-key not set\")\n\tErrFromNotSet             = errors.New(\"from not set\")\n\tErrToNotSet               = errors.New(\"to not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tAPIKey string `yaml:\"api-key\"`\n\tFrom   string `yaml:\"from\"`\n\tTo     string `yaml:\"to\"`\n\n\t// ClientConfig is the configuration of the client used to communicate with the provider's target\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.APIKey) == 0 {\n\t\treturn ErrAPIKeyNotSet\n\t}\n\tif len(cfg.From) == 0 {\n\t\treturn ErrFromNotSet\n\t}\n\tif len(cfg.To) == 0 {\n\t\treturn ErrToNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif override.ClientConfig != nil {\n\t\tcfg.ClientConfig = override.ClientConfig\n\t}\n\tif len(override.APIKey) > 0 {\n\t\tcfg.APIKey = override.APIKey\n\t}\n\tif len(override.From) > 0 {\n\t\tcfg.From = override.From\n\t}\n\tif len(override.To) > 0 {\n\t\tcfg.To = override.To\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using SendGrid\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsubject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)\n\tpayload := provider.buildSendGridPayload(cfg, subject, body)\n\tpayloadBytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest, err := http.NewRequest(http.MethodPost, ApiURL, bytes.NewBuffer(payloadBytes))\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Authorization\", \"Bearer \"+cfg.APIKey)\n\tresponse, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to sendgrid alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\ntype SendGridPayload struct {\n\tPersonalizations []Personalization `json:\"personalizations\"`\n\tFrom             Email             `json:\"from\"`\n\tSubject          string            `json:\"subject\"`\n\tContent          []Content         `json:\"content\"`\n}\n\ntype Personalization struct {\n\tTo []Email `json:\"to\"`\n}\n\ntype Email struct {\n\tEmail string `json:\"email\"`\n}\n\ntype Content struct {\n\tType  string `json:\"type\"`\n\tValue string `json:\"value\"`\n}\n\n// buildSendGridPayload builds the SendGrid API payload\nfunc (provider *AlertProvider) buildSendGridPayload(cfg *Config, subject, body string) SendGridPayload {\n\ttoEmails := strings.Split(cfg.To, \",\")\n\tvar recipients []Email\n\tfor _, email := range toEmails {\n\t\trecipients = append(recipients, Email{Email: strings.TrimSpace(email)})\n\t}\n\treturn SendGridPayload{\n\t\tPersonalizations: []Personalization{\n\t\t\t{\n\t\t\t\tTo: recipients,\n\t\t\t},\n\t\t},\n\t\tFrom: Email{\n\t\t\tEmail: cfg.From,\n\t\t},\n\t\tSubject: subject,\n\t\tContent: []Content{\n\t\t\t{\n\t\t\t\tType:  \"text/plain\",\n\t\t\t\tValue: body,\n\t\t\t},\n\t\t\t{\n\t\t\t\tType:  \"text/html\",\n\t\t\t\tValue: strings.ReplaceAll(body, \"\\n\", \"<br>\"),\n\t\t\t},\n\t\t},\n\t}\n}\n\n// buildMessageSubjectAndBody builds the message subject and body\nfunc (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {\n\tvar subject, message string\n\tif resolved {\n\t\tsubject = fmt.Sprintf(\"[%s] Alert resolved\", ep.DisplayName())\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tsubject = fmt.Sprintf(\"[%s] Alert triggered\", ep.DisplayName())\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tvar formattedConditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tformattedConditionResults = \"\\n\\nCondition results:\\n\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \"✅\"\n\t\t\t} else {\n\t\t\t\tprefix = \"❌\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"%s %s\\n\", prefix, conditionResult.Condition)\n\t\t}\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \"\\n\\nAlert description: \" + alertDescription\n\t}\n\tvar extraLabels string\n\tif len(ep.ExtraLabels) > 0 {\n\t\textraLabels = \"\\n\\nExtra labels:\\n\"\n\t\tfor key, value := range ep.ExtraLabels {\n\t\t\textraLabels += fmt.Sprintf(\"  %s: %s\\n\", key, value)\n\t\t}\n\t}\n\treturn subject, message + description + extraLabels + formattedConditionResults\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/sendgrid/sendgrid_test.go",
    "content": "package sendgrid\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{APIKey: \"\", From: \"\", To: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey: \"SG.test\",\n\t\t\tFrom:   \"from@example.com\",\n\t\t\tTo:     \"to@example.com\",\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"to@example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider with empty Group should not have been valid\")\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err != ErrDuplicateGroupOverride {\n\t\tt.Error(\"provider with empty Group should return ErrDuplicateGroupOverride\")\n\t}\n\tproviderWithDuplicateOverrideGroups := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey: \"SG.test\",\n\t\t\tFrom:   \"from@example.com\",\n\t\t\tTo:     \"to@example.com\",\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"to1@example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"to2@example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithDuplicateOverrideGroups.Validate(); err == nil {\n\t\tt.Error(\"provider with duplicate group overrides should not have been valid\")\n\t}\n\tif err := providerWithDuplicateOverrideGroups.Validate(); err != ErrDuplicateGroupOverride {\n\t\tt.Error(\"provider with duplicate group overrides should return ErrDuplicateGroupOverride\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey: \"SG.test\",\n\t\t\tFrom:   \"from@example.com\",\n\t\t\tTo:     \"to@example.com\",\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"to@example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n\tproviderWithValidMultipleOverrides := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey: \"SG.test\",\n\t\t\tFrom:   \"from@example.com\",\n\t\t\tTo:     \"to@example.com\",\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"group1@example.com\"},\n\t\t\t\tGroup:  \"group1\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tConfig: Config{To: \"group2@example.com\"},\n\t\t\t\tGroup:  \"group2\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidMultipleOverrides.Validate(); err != nil {\n\t\tt.Error(\"provider with multiple valid overrides should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(`{\"errors\": [{\"message\": \"Invalid API key\"}]}`))}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildSendGridPayload(t *testing.T) {\n\tprovider := &AlertProvider{}\n\tcfg := &Config{\n\t\tFrom: \"test@example.com\",\n\t\tTo:   \"to1@example.com,to2@example.com\",\n\t}\n\tsubject := \"Test Subject\"\n\tbody := \"Test Body\\nWith new line\"\n\tpayload := provider.buildSendGridPayload(cfg, subject, body)\n\tif payload.Subject != subject {\n\t\tt.Errorf(\"expected subject to be %s, got %s\", subject, payload.Subject)\n\t}\n\tif payload.From.Email != cfg.From {\n\t\tt.Errorf(\"expected from email to be %s, got %s\", cfg.From, payload.From.Email)\n\t}\n\tif len(payload.Personalizations) != 1 {\n\t\tt.Errorf(\"expected 1 personalization, got %d\", len(payload.Personalizations))\n\t}\n\tif len(payload.Personalizations[0].To) != 2 {\n\t\tt.Errorf(\"expected 2 recipients, got %d\", len(payload.Personalizations[0].To))\n\t}\n\tif payload.Personalizations[0].To[0].Email != \"to1@example.com\" {\n\t\tt.Errorf(\"expected first recipient to be to1@example.com, got %s\", payload.Personalizations[0].To[0].Email)\n\t}\n\tif payload.Personalizations[0].To[1].Email != \"to2@example.com\" {\n\t\tt.Errorf(\"expected second recipient to be to2@example.com, got %s\", payload.Personalizations[0].To[1].Email)\n\t}\n\tif len(payload.Content) != 2 {\n\t\tt.Errorf(\"expected 2 content types, got %d\", len(payload.Content))\n\t}\n\tif payload.Content[0].Type != \"text/plain\" {\n\t\tt.Errorf(\"expected first content type to be text/plain, got %s\", payload.Content[0].Type)\n\t}\n\tif payload.Content[0].Value != body {\n\t\tt.Errorf(\"expected plain text content to be %s, got %s\", body, payload.Content[0].Value)\n\t}\n\tif payload.Content[1].Type != \"text/html\" {\n\t\tt.Errorf(\"expected second content type to be text/html, got %s\", payload.Content[1].Type)\n\t}\n\texpectedHTML := \"Test Body<br>With new line\"\n\tif payload.Content[1].Value != expectedHTML {\n\t\tt.Errorf(\"expected HTML content to be %s, got %s\", expectedHTML, payload.Content[1].Value)\n\t}\n}\n\nfunc TestAlertProvider_buildMessageSubjectAndBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName            string\n\t\tProvider        AlertProvider\n\t\tAlert           alert.Alert\n\t\tResolved        bool\n\t\tEndpoint        *endpoint.Endpoint\n\t\tExpectedSubject string\n\t\tExpectedBody    string\n\t}{\n\t\t{\n\t\t\tName:            \"triggered\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        false,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert triggered\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t\t{\n\t\t\tName:            \"resolved\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        true,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert resolved\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t\t{\n\t\t\tName:            \"triggered-with-single-extra-label\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        false,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\", ExtraLabels: map[string]string{\"environment\": \"production\"}},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert triggered\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t\t{\n\t\t\tName:            \"resolved-with-single-extra-label\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        true,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\", ExtraLabels: map[string]string{\"service\": \"api\"}},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert resolved\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t\t{\n\t\t\tName:            \"triggered-with-no-extra-labels\",\n\t\t\tProvider:        AlertProvider{},\n\t\t\tAlert:           alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        false,\n\t\t\tEndpoint:        &endpoint.Endpoint{Name: \"endpoint-name\", ExtraLabels: map[string]string{}},\n\t\t\tExpectedSubject: \"[endpoint-name] Alert triggered\",\n\t\t\tExpectedBody:    \"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\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tsubject, body := scenario.Provider.buildMessageSubjectAndBody(\n\t\t\t\tscenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif subject != scenario.ExpectedSubject {\n\t\t\t\tt.Errorf(\"expected subject to be %s, got %s\", scenario.ExpectedSubject, subject)\n\t\t\t}\n\t\t\tif body != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected body to be %s, got %s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{To: \"to01@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{To: \"group-to@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"group-to@example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{To: \"group-to@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"api-key\": \"SG.override\", \"to\": \"alert-to@example.com\", \"from\": \"alert-from@example.com\"}},\n\t\t\tExpectedOutput: Config{APIKey: \"SG.override\", From: \"alert-from@example.com\", To: \"alert-to@example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-multiple-overrides-pick-correct-group\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{APIKey: \"SG.default\", From: \"default@example.com\", To: \"default@example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group1\",\n\t\t\t\t\t\tConfig: Config{APIKey: \"SG.group1\", To: \"group1@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group2\",\n\t\t\t\t\t\tConfig: Config{APIKey: \"SG.group2\", From: \"group2@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group2\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{APIKey: \"SG.group2\", From: \"group2@example.com\", To: \"default@example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-partial-override-hierarchy\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{APIKey: \"SG.default\", From: \"default@example.com\", To: \"default@example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"test-group\",\n\t\t\t\t\t\tConfig: Config{From: \"group@example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"test-group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"to\": \"alert@example.com\"}},\n\t\t\tExpectedOutput: Config{APIKey: \"SG.default\", From: \"group@example.com\", To: \"alert@example.com\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.APIKey != scenario.ExpectedOutput.APIKey {\n\t\t\t\tt.Errorf(\"expected APIKey to be %s, got %s\", scenario.ExpectedOutput.APIKey, got.APIKey)\n\t\t\t}\n\t\t\tif got.From != scenario.ExpectedOutput.From {\n\t\t\t\tt.Errorf(\"expected From to be %s, got %s\", scenario.ExpectedOutput.From, got.From)\n\t\t\t}\n\t\t\tif got.To != scenario.ExpectedOutput.To {\n\t\t\t\tt.Errorf(\"expected To to be %s, got %s\", scenario.ExpectedOutput.To, got.To)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tName          string\n\t\tConfig        Config\n\t\tExpectedError error\n\t}{\n\t\t{\n\t\t\tName:          \"missing-api-key\",\n\t\t\tConfig:        Config{APIKey: \"\", From: \"test@example.com\", To: \"to@example.com\"},\n\t\t\tExpectedError: ErrAPIKeyNotSet,\n\t\t},\n\t\t{\n\t\t\tName:          \"missing-from\",\n\t\t\tConfig:        Config{APIKey: \"SG.test\", From: \"\", To: \"to@example.com\"},\n\t\t\tExpectedError: ErrFromNotSet,\n\t\t},\n\t\t{\n\t\t\tName:          \"missing-to\",\n\t\t\tConfig:        Config{APIKey: \"SG.test\", From: \"test@example.com\", To: \"\"},\n\t\t\tExpectedError: ErrToNotSet,\n\t\t},\n\t\t{\n\t\t\tName:          \"valid-config\",\n\t\t\tConfig:        Config{APIKey: \"SG.test\", From: \"test@example.com\", To: \"to@example.com\"},\n\t\t\tExpectedError: nil,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\terr := scenario.Config.Validate()\n\t\t\tif scenario.ExpectedError == nil && err != nil {\n\t\t\t\tt.Errorf(\"expected no error, got %v\", err)\n\t\t\t}\n\t\t\tif scenario.ExpectedError != nil && err == nil {\n\t\t\t\tt.Errorf(\"expected error %v, got none\", scenario.ExpectedError)\n\t\t\t}\n\t\t\tif scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.ExpectedError, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_Merge(t *testing.T) {\n\tconfig := Config{APIKey: \"SG.original\", From: \"from@example.com\", To: \"to@example.com\"}\n\toverride := Config{APIKey: \"SG.override\", To: \"override@example.com\"}\n\tconfig.Merge(&override)\n\tif config.APIKey != \"SG.override\" {\n\t\tt.Errorf(\"expected APIKey to be SG.override, got %s\", config.APIKey)\n\t}\n\tif config.From != \"from@example.com\" {\n\t\tt.Errorf(\"expected From to remain from@example.com, got %s\", config.From)\n\t}\n\tif config.To != \"override@example.com\" {\n\t\tt.Errorf(\"expected To to be override@example.com, got %s\", config.To)\n\t}\n}\n\nfunc TestConfig_MergeWithClientConfig(t *testing.T) {\n\tconfig := Config{APIKey: \"SG.original\", From: \"from@example.com\", To: \"to@example.com\"}\n\toverride := Config{APIKey: \"SG.override\", ClientConfig: &client.Config{Timeout: 30000}}\n\tconfig.Merge(&override)\n\tif config.APIKey != \"SG.override\" {\n\t\tt.Errorf(\"expected APIKey to be SG.override, got %s\", config.APIKey)\n\t}\n\tif config.ClientConfig == nil {\n\t\tt.Error(\"expected ClientConfig to be set\")\n\t}\n\tif config.ClientConfig.Timeout != 30000 {\n\t\tt.Errorf(\"expected ClientConfig.Timeout to be 30000, got %d\", config.ClientConfig.Timeout)\n\t}\n\tconfig2 := Config{APIKey: \"SG.test\", From: \"from@example.com\", To: \"to@example.com\", ClientConfig: &client.Config{Timeout: 10000}}\n\toverride2 := Config{APIKey: \"SG.override2\"}\n\tconfig2.Merge(&override2)\n\tif config2.ClientConfig.Timeout != 10000 {\n\t\tt.Errorf(\"expected ClientConfig.Timeout to remain 10000, got %d\", config2.ClientConfig.Timeout)\n\t}\n}"
  },
  {
    "path": "alerting/provider/signal/signal.go",
    "content": "package signal\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrApiURLNotSet           = errors.New(\"api-url not set\")\n\tErrNumberNotSet           = errors.New(\"number not set\")\n\tErrRecipientsNotSet       = errors.New(\"recipients not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tApiURL     string   `yaml:\"api-url\"`    // Signal API URL (e.g., signal-cli-rest-api instance)\n\tNumber     string   `yaml:\"number\"`     // Sender phone number\n\tRecipients []string `yaml:\"recipients\"` // List of recipient phone numbers\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.ApiURL) == 0 {\n\t\treturn ErrApiURLNotSet\n\t}\n\tif !strings.HasSuffix(cfg.ApiURL, \"/v2/send\") {\n\t\tcfg.ApiURL = cfg.ApiURL + \"/v2/send\"\n\t}\n\tif len(cfg.Number) == 0 {\n\t\treturn ErrNumberNotSet\n\t}\n\tif len(cfg.Recipients) == 0 {\n\t\treturn ErrRecipientsNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.ApiURL) > 0 {\n\t\tcfg.ApiURL = override.ApiURL\n\t}\n\tif len(override.Number) > 0 {\n\t\tcfg.Number = override.Number\n\t}\n\tif len(override.Recipients) > 0 {\n\t\tcfg.Recipients = override.Recipients\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Signal\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, recipient := range cfg.Recipients {\n\t\tbody, err := provider.buildRequestBody(cfg, ep, alert, result, resolved, recipient)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbuffer := bytes.NewBuffer(body)\n\t\trequest, err := http.NewRequest(http.MethodPost, cfg.ApiURL, buffer)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif response.StatusCode >= 400 {\n\t\t\tbody, _ := io.ReadAll(response.Body)\n\t\t\tresponse.Body.Close()\n\t\t\treturn fmt.Errorf(\"call to signal alert returned status code %d: %s\", response.StatusCode, string(body))\n\t\t}\n\t\tresponse.Body.Close()\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tMessage    string   `json:\"message\"`\n\tNumber     string   `json:\"number\"`\n\tRecipients []string `json:\"recipients\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, recipient string) ([]byte, error) {\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"🟢 RESOLVED: %s\\nAlert has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"🔴 ALERT: %s\\nEndpoint has failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tmessage += fmt.Sprintf(\"\\n\\nDescription: %s\", alertDescription)\n\t}\n\tif len(result.ConditionResults) > 0 {\n\t\tmessage += \"\\n\\nCondition results:\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar status string\n\t\t\tif conditionResult.Success {\n\t\t\t\tstatus = \"✅\"\n\t\t\t} else {\n\t\t\t\tstatus = \"❌\"\n\t\t\t}\n\t\t\tmessage += fmt.Sprintf(\"\\n%s %s\", status, conditionResult.Condition)\n\t\t}\n\t}\n\tbody := Body{\n\t\tMessage:    message,\n\t\tNumber:     cfg.Number,\n\t\tRecipients: []string{recipient},\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/signal/signal_test.go",
    "content": "package signal\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ApiURL: \"http://localhost:8080\", Number: \"+1234567890\", Recipients: []string{\"+0987654321\"}}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-api-url\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{Number: \"+1234567890\", Recipients: []string{\"+0987654321\"}}},\n\t\t\texpected: ErrApiURLNotSet,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-number\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ApiURL: \"http://localhost:8080\", Recipients: []string{\"+0987654321\"}}},\n\t\t\texpected: ErrNumberNotSet,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-recipients\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ApiURL: \"http://localhost:8080\", Number: \"+1234567890\"}},\n\t\t\texpected: ErrRecipientsNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ApiURL: \"http://localhost:8080\", Number: \"+1234567890\", Recipients: []string{\"+0987654321\", \"+1111111111\"}}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.URL.Path != \"/v2/send\" {\n\t\t\t\t\tt.Errorf(\"expected path /v2/send, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"number\"] != \"+1234567890\" {\n\t\t\t\t\tt.Errorf(\"expected number to be '+1234567890', got %v\", body[\"number\"])\n\t\t\t\t}\n\t\t\t\trecipients := body[\"recipients\"].([]interface{})\n\t\t\t\tif len(recipients) != 1 {\n\t\t\t\t\tt.Errorf(\"expected 1 recipient per request, got %d\", len(recipients))\n\t\t\t\t}\n\t\t\t\tmessage := body[\"message\"].(string)\n\t\t\t\tif !strings.Contains(message, \"ALERT\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain 'ALERT', got %s\", message)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(message, \"failed 3 time(s)\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain failure count, got %s\", message)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ApiURL: \"http://localhost:8080\", Number: \"+1234567890\", Recipients: []string{\"+0987654321\"}}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tmessage := body[\"message\"].(string)\n\t\t\t\tif !strings.Contains(message, \"RESOLVED\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain 'RESOLVED', got %s\", message)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{ApiURL: \"http://localhost:8080\", Number: \"+1234567890\", Recipients: []string{\"+0987654321\"}}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/signl4/signl4.go",
    "content": "package signl4\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrTeamSecretNotSet       = errors.New(\"team-secret not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tTeamSecret string `yaml:\"team-secret\"` // SIGNL4 team secret\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.TeamSecret) == 0 {\n\t\treturn ErrTeamSecretNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.TeamSecret) > 0 {\n\t\tcfg.TeamSecret = override.TeamSecret\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using SIGNL4\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbody, err := provider.buildRequestBody(ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(body)\n\twebhookURL := fmt.Sprintf(\"https://connect.signl4.com/webhook/%s\", cfg.TeamSecret)\n\trequest, err := http.NewRequest(http.MethodPost, webhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to signl4 alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tTitle         string `json:\"Title\"`\n\tMessage       string `json:\"Message\"`\n\tXS4Service    string `json:\"X-S4-Service\"`\n\tXS4Status     string `json:\"X-S4-Status\"`\n\tXS4ExternalID string `json:\"X-S4-ExternalID\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {\n\tvar title, message, status string\n\tif resolved {\n\t\ttitle = fmt.Sprintf(\"RESOLVED: %s\", ep.DisplayName())\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tstatus = \"resolved\"\n\t} else {\n\t\ttitle = fmt.Sprintf(\"TRIGGERED: %s\", ep.DisplayName())\n\t\tmessage = fmt.Sprintf(\"An alert for %s has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t\tstatus = \"new\"\n\t}\n\tvar conditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tconditionResults = \"\\n\\nCondition results:\\n\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \"✓\"\n\t\t\t} else {\n\t\t\t\tprefix = \"✗\"\n\t\t\t}\n\t\t\tconditionResults += fmt.Sprintf(\"%s %s\\n\", prefix, conditionResult.Condition)\n\t\t}\n\t}\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tmessage += \"\\n\\nDescription: \" + alertDescription\n\t}\n\tmessage += conditionResults\n\tbody := Body{\n\t\tTitle:         title,\n\t\tMessage:       message,\n\t\tXS4Service:    ep.DisplayName(),\n\t\tXS4Status:     status,\n\t\tXS4ExternalID: fmt.Sprintf(\"gatus-%s\", ep.Key()),\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}"
  },
  {
    "path": "alerting/provider/signl4/signl4_test.go",
    "content": "package signl4\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{TeamSecret: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{TeamSecret: \"team-secret-123\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{TeamSecret: \"team-secret-123\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{TeamSecret: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider team secret shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{TeamSecret: \"team-secret-123\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{TeamSecret: \"team-secret-override\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{TeamSecret: \"team-secret-123\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{TeamSecret: \"team-secret-123\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{TeamSecret: \"team-secret-123\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{TeamSecret: \"team-secret-123\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tEndpoint     endpoint.Endpoint\n\t\tAlert        alert.Alert\n\t\tNoConditions bool\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\"},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-group\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\", Group: \"group\"},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-no-conditions\",\n\t\t\tNoConditions: true,\n\t\t\tProvider:     AlertProvider{},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\"},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\"},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-with-group\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\", Group: \"group\"},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif !scenario.NoConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t}\n\t\t\t}\n\t\t\tbody, err := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: conditionResults,\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"buildRequestBody returned an error: %v\", err)\n\t\t\t}\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{TeamSecret: \"team-secret-123\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{TeamSecret: \"team-secret-123\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{TeamSecret: \"team-secret-123\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{TeamSecret: \"team-secret-123\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{TeamSecret: \"team-secret-123\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{TeamSecret: \"team-secret-override\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{TeamSecret: \"team-secret-123\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{TeamSecret: \"team-secret-123\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{TeamSecret: \"team-secret-override\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{TeamSecret: \"team-secret-override\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{TeamSecret: \"team-secret-123\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{TeamSecret: \"team-secret-override\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"team-secret\": \"team-secret-alert\"}},\n\t\t\tExpectedOutput: Config{TeamSecret: \"team-secret-alert\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.TeamSecret != scenario.ExpectedOutput.TeamSecret {\n\t\t\t\tt.Errorf(\"expected team secret to be %s, got %s\", scenario.ExpectedOutput.TeamSecret, got.TeamSecret)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetConfigWithInvalidAlertOverride(t *testing.T) {\n\t// Test case 1: Empty override should be ignored, default config should be used\n\tprovider := AlertProvider{\n\t\tDefaultConfig: Config{TeamSecret: \"team-secret-123\"},\n\t}\n\talertWithEmptyOverride := alert.Alert{\n\t\tProviderOverride: map[string]any{\"team-secret\": \"\"},\n\t}\n\tcfg, err := provider.GetConfig(\"\", &alertWithEmptyOverride)\n\tif err != nil {\n\t\tt.Errorf(\"unexpected error: %v\", err)\n\t}\n\tif cfg.TeamSecret != \"team-secret-123\" {\n\t\tt.Errorf(\"expected team secret to remain default 'team-secret-123', got %s\", cfg.TeamSecret)\n\t}\n\n\t// Test case 2: Invalid default config with no valid override should fail\n\tproviderWithInvalidDefault := AlertProvider{\n\t\tDefaultConfig: Config{TeamSecret: \"\"},\n\t}\n\talertWithEmptyOverride2 := alert.Alert{\n\t\tProviderOverride: map[string]any{\"team-secret\": \"\"},\n\t}\n\t_, err = providerWithInvalidDefault.GetConfig(\"\", &alertWithEmptyOverride2)\n\tif err == nil {\n\t\tt.Error(\"expected error due to invalid default config, got none\")\n\t}\n\tif err != ErrTeamSecretNotSet {\n\t\tt.Errorf(\"expected ErrTeamSecretNotSet, got %v\", err)\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithDuplicateGroupOverride(t *testing.T) {\n\tproviderWithDuplicateOverride := AlertProvider{\n\t\tDefaultConfig: Config{TeamSecret: \"team-secret-123\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tGroup:  \"group1\",\n\t\t\t\tConfig: Config{TeamSecret: \"team-secret-override-1\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tGroup:  \"group1\",\n\t\t\t\tConfig: Config{TeamSecret: \"team-secret-override-2\"},\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithDuplicateOverride.Validate(); err == nil {\n\t\tt.Error(\"provider should not have been valid due to duplicate group override\")\n\t}\n\tif err := providerWithDuplicateOverride.Validate(); err != ErrDuplicateGroupOverride {\n\t\tt.Errorf(\"expected ErrDuplicateGroupOverride, got %v\", providerWithDuplicateOverride.Validate())\n\t}\n}\n\nfunc TestAlertProvider_ValidateOverridesWithInvalidAlert(t *testing.T) {\n\tprovider := AlertProvider{\n\t\tDefaultConfig: Config{TeamSecret: \"\"},\n\t}\n\talertWithEmptyOverride := alert.Alert{\n\t\tProviderOverride: map[string]any{\"team-secret\": \"\"},\n\t}\n\terr := provider.ValidateOverrides(\"\", &alertWithEmptyOverride)\n\tif err == nil {\n\t\tt.Error(\"expected error due to invalid default config, got none\")\n\t}\n\tif err != ErrTeamSecretNotSet {\n\t\tt.Errorf(\"expected ErrTeamSecretNotSet, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/slack/slack.go",
    "content": "package slack\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL string `yaml:\"webhook-url\"`     // Slack webhook URL\n\tTitle      string `yaml:\"title,omitempty\"` // Title of the message that will be sent\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n\tif len(override.Title) > 0 {\n\t\tcfg.Title = override.Title\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Slack\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tText        string       `json:\"text\"`\n\tAttachments []Attachment `json:\"attachments\"`\n}\n\ntype Attachment struct {\n\tTitle  string  `json:\"title\"`\n\tText   string  `json:\"text\"`\n\tShort  bool    `json:\"short\"`\n\tColor  string  `json:\"color\"`\n\tFields []Field `json:\"fields,omitempty\"`\n}\n\ntype Field struct {\n\tTitle string `json:\"title\"`\n\tValue string `json:\"value\"`\n\tShort bool   `json:\"short\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message, color string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tcolor = \"#36A64F\"\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t\tcolor = \"#DD0000\"\n\t}\n\tvar formattedConditionResults string\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \":white_check_mark:\"\n\t\t} else {\n\t\t\tprefix = \":x:\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"%s - `%s`\\n\", prefix, conditionResult.Condition)\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \":\\n> \" + alertDescription\n\t}\n\tbody := Body{\n\t\tText: \"\",\n\t\tAttachments: []Attachment{\n\t\t\t{\n\t\t\t\tTitle: cfg.Title,\n\t\t\t\tText:  message + description,\n\t\t\t\tShort: false,\n\t\t\t\tColor: color,\n\t\t\t},\n\t\t},\n\t}\n\tif len(body.Attachments[0].Title) == 0 {\n\t\tbody.Attachments[0].Title = \":helmet_with_white_cross: Gatus\"\n\t}\n\tif len(formattedConditionResults) > 0 {\n\t\tbody.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{\n\t\t\tTitle: \"Condition results\",\n\t\t\tValue: formattedConditionResults,\n\t\t\tShort: false,\n\t\t})\n\t}\n\tbodyAsJSON, _ := json.Marshal(body)\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/slack/slack_test.go",
    "content": "package slack\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"https://example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider integration key shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tEndpoint     endpoint.Endpoint\n\t\tAlert        alert.Alert\n\t\tNoConditions bool\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\"},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-group\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\", Group: \"group\"},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-no-conditions\",\n\t\t\tNoConditions: true,\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\"},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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\\\"}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\"},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-with-group\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\", Group: \"group\"},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-with-group-and-custom-title\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\", Title: \"custom title\"}},\n\t\t\tEndpoint:     endpoint.Endpoint{Name: \"name\", Group: \"group\"},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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}]}]}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif !scenario.NoConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t}\n\t\t\t}\n\t\t\tcfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(\"couldn't get config:\", err.Error())\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\tcfg,\n\t\t\t\t&scenario.Endpoint,\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: conditionResults,\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://example01.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://group-example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"webhook-url\": \"http://alert-example.com\"}},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://alert-example.com\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.WebhookURL != scenario.ExpectedOutput.WebhookURL {\n\t\t\t\tt.Errorf(\"expected webhook URL to be %s, got %s\", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/splunk/splunk.go",
    "content": "package splunk\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrHecURLNotSet           = errors.New(\"hec-url not set\")\n\tErrHecTokenNotSet         = errors.New(\"hec-token not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tHecURL     string `yaml:\"hec-url\"`              // Splunk HEC (HTTP Event Collector) URL\n\tHecToken   string `yaml:\"hec-token\"`            // Splunk HEC token\n\tSource     string `yaml:\"source,omitempty\"`     // Event source\n\tSourceType string `yaml:\"sourcetype,omitempty\"` // Event source type\n\tIndex      string `yaml:\"index,omitempty\"`      // Splunk index\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.HecURL) == 0 {\n\t\treturn ErrHecURLNotSet\n\t}\n\tif len(cfg.HecToken) == 0 {\n\t\treturn ErrHecTokenNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.HecURL) > 0 {\n\t\tcfg.HecURL = override.HecURL\n\t}\n\tif len(override.HecToken) > 0 {\n\t\tcfg.HecToken = override.HecToken\n\t}\n\tif len(override.Source) > 0 {\n\t\tcfg.Source = override.Source\n\t}\n\tif len(override.SourceType) > 0 {\n\t\tcfg.SourceType = override.SourceType\n\t}\n\tif len(override.Index) > 0 {\n\t\tcfg.Index = override.Index\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Splunk\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbody, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(body)\n\trequest, err := http.NewRequest(http.MethodPost, fmt.Sprintf(\"%s/services/collector/event\", cfg.HecURL), buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\trequest.Header.Set(\"Authorization\", fmt.Sprintf(\"Splunk %s\", cfg.HecToken))\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to splunk alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tTime       int64  `json:\"time\"`\n\tSource     string `json:\"source,omitempty\"`\n\tSourceType string `json:\"sourcetype,omitempty\"`\n\tIndex      string `json:\"index,omitempty\"`\n\tEvent      Event  `json:\"event\"`\n}\n\ntype Event struct {\n\tAlertType   string                      `json:\"alert_type\"`\n\tEndpoint    string                      `json:\"endpoint\"`\n\tGroup       string                      `json:\"group,omitempty\"`\n\tStatus      string                      `json:\"status\"`\n\tMessage     string                      `json:\"message\"`\n\tDescription string                      `json:\"description,omitempty\"`\n\tConditions  []*endpoint.ConditionResult `json:\"conditions,omitempty\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {\n\tvar alertType, status, message string\n\tif resolved {\n\t\talertType = \"resolved\"\n\t\tstatus = \"ok\"\n\t\tmessage = fmt.Sprintf(\"Alert for %s has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\talertType = \"triggered\"\n\t\tstatus = \"critical\"\n\t\tmessage = fmt.Sprintf(\"Alert for %s has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tevent := Event{\n\t\tAlertType:   alertType,\n\t\tEndpoint:    ep.DisplayName(),\n\t\tGroup:       ep.Group,\n\t\tStatus:      status,\n\t\tMessage:     message,\n\t\tDescription: alert.GetDescription(),\n\t}\n\tif len(result.ConditionResults) > 0 {\n\t\tevent.Conditions = result.ConditionResults\n\t}\n\tbody := Body{\n\t\tTime:  time.Now().Unix(),\n\t\tEvent: event,\n\t}\n\t// Set optional fields\n\tif cfg.Source != \"\" {\n\t\tbody.Source = cfg.Source\n\t} else {\n\t\tbody.Source = \"gatus\"\n\t}\n\tif cfg.SourceType != \"\" {\n\t\tbody.SourceType = cfg.SourceType\n\t} else {\n\t\tbody.SourceType = \"gatus:alert\"\n\t}\n\tif cfg.Index != \"\" {\n\t\tbody.Index = cfg.Index\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/splunk/splunk_test.go",
    "content": "package splunk\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{HecURL: \"https://splunk.example.com:8088\", HecToken: \"token123\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid-with-index\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{HecURL: \"https://splunk.example.com:8088\", HecToken: \"token123\", Index: \"main\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-hec-url\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{HecToken: \"token123\"}},\n\t\t\texpected: ErrHecURLNotSet,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-hec-token\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{HecURL: \"https://splunk.example.com:8088\"}},\n\t\t\texpected: ErrHecTokenNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{HecURL: \"https://splunk.example.com:8088\", HecToken: \"token123\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.URL.Path != \"/services/collector/event\" {\n\t\t\t\t\tt.Errorf(\"expected path /services/collector/event, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tif r.Header.Get(\"Authorization\") != \"Splunk token123\" {\n\t\t\t\t\tt.Errorf(\"expected Authorization header to be 'Splunk token123', got %s\", r.Header.Get(\"Authorization\"))\n\t\t\t\t}\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"time\"] == nil {\n\t\t\t\t\tt.Error(\"expected 'time' field in request body\")\n\t\t\t\t}\n\t\t\t\tevent := body[\"event\"].(map[string]interface{})\n\t\t\t\tif event[\"alert_type\"] != \"triggered\" {\n\t\t\t\t\tt.Errorf(\"expected alert_type to be 'triggered', got %v\", event[\"alert_type\"])\n\t\t\t\t}\n\t\t\t\tif event[\"status\"] != \"critical\" {\n\t\t\t\t\tt.Errorf(\"expected status to be 'critical', got %v\", event[\"status\"])\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{HecURL: \"https://splunk.example.com:8088\", HecToken: \"token123\", Index: \"main\"}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"index\"] != \"main\" {\n\t\t\t\t\tt.Errorf(\"expected index to be 'main', got %v\", body[\"index\"])\n\t\t\t\t}\n\t\t\t\tevent := body[\"event\"].(map[string]interface{})\n\t\t\t\tif event[\"alert_type\"] != \"resolved\" {\n\t\t\t\t\tt.Errorf(\"expected alert_type to be 'resolved', got %v\", event[\"alert_type\"])\n\t\t\t\t}\n\t\t\t\tif event[\"status\"] != \"ok\" {\n\t\t\t\t\tt.Errorf(\"expected status to be 'ok', got %v\", event[\"status\"])\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{HecURL: \"https://splunk.example.com:8088\", HecToken: \"token123\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/squadcast/squadcast.go",
    "content": "package squadcast\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL string `yaml:\"webhook-url\"` // Squadcast webhook URL\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Squadcast\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbody, err := provider.buildRequestBody(ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(body)\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to squadcast alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tMessage     string            `json:\"message\"`\n\tDescription string            `json:\"description,omitempty\"`\n\tEventID     string            `json:\"event_id\"`\n\tStatus      string            `json:\"status\"`\n\tTags        map[string]string `json:\"tags,omitempty\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {\n\tvar message, status string\n\teventID := fmt.Sprintf(\"gatus-%s\", ep.Key())\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"RESOLVED: %s\", ep.DisplayName())\n\t\tstatus = \"resolve\"\n\t} else {\n\t\tmessage = fmt.Sprintf(\"ALERT: %s\", ep.DisplayName())\n\t\tstatus = \"trigger\"\n\t}\n\tdescription := fmt.Sprintf(\"Endpoint: %s\\n\", ep.DisplayName())\n\tif resolved {\n\t\tdescription += fmt.Sprintf(\"Alert has been resolved after passing successfully %d time(s) in a row\\n\", alert.SuccessThreshold)\n\t} else {\n\t\tdescription += fmt.Sprintf(\"Endpoint has failed %d time(s) in a row\\n\", alert.FailureThreshold)\n\t}\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription += fmt.Sprintf(\"\\nDescription: %s\", alertDescription)\n\t}\n\tif len(result.ConditionResults) > 0 {\n\t\tdescription += \"\\n\\nCondition Results:\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar status string\n\t\t\tif conditionResult.Success {\n\t\t\t\tstatus = \"✅\"\n\t\t\t} else {\n\t\t\t\tstatus = \"❌\"\n\t\t\t}\n\t\t\tdescription += fmt.Sprintf(\"\\n%s %s\", status, conditionResult.Condition)\n\t\t}\n\t}\n\tbody := Body{\n\t\tMessage:     message,\n\t\tDescription: description,\n\t\tEventID:     eventID,\n\t\tStatus:      status,\n\t\tTags: map[string]string{\n\t\t\t\"endpoint\": ep.Name,\n\t\t\t\"group\":    ep.Group,\n\t\t\t\"source\":   \"gatus\",\n\t\t},\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/squadcast/squadcast_test.go",
    "content": "package squadcast\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://api.squadcast.com/v3/incidents/api/abcd1234\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-webhook-url\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{}},\n\t\t\texpected: ErrWebhookURLNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://api.squadcast.com/v3/incidents/api/abcd1234\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"status\"] != \"trigger\" {\n\t\t\t\t\tt.Errorf(\"expected status to be 'trigger', got %v\", body[\"status\"])\n\t\t\t\t}\n\t\t\t\tif body[\"event_id\"] == nil {\n\t\t\t\t\tt.Error(\"expected 'event_id' field in request body\")\n\t\t\t\t}\n\t\t\t\tmessage := body[\"message\"].(string)\n\t\t\t\tif !strings.Contains(message, \"ALERT\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain 'ALERT', got %s\", message)\n\t\t\t\t}\n\t\t\t\tdescription := body[\"description\"].(string)\n\t\t\t\tif !strings.Contains(description, \"failed 3 time(s)\") {\n\t\t\t\t\tt.Errorf(\"expected description to contain failure count, got %s\", description)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://api.squadcast.com/v3/incidents/api/abcd1234\"}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"status\"] != \"resolve\" {\n\t\t\t\t\tt.Errorf(\"expected status to be 'resolve', got %v\", body[\"status\"])\n\t\t\t\t}\n\t\t\t\tmessage := body[\"message\"].(string)\n\t\t\t\tif !strings.Contains(message, \"RESOLVED\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain 'RESOLVED', got %s\", message)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://api.squadcast.com/v3/incidents/api/abcd1234\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}"
  },
  {
    "path": "alerting/provider/teams/teams.go",
    "content": "package teams\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL string `yaml:\"webhook-url\"`\n\tTitle      string `yaml:\"title,omitempty\"` // Title of the message that will be sent\n\n\t// ClientConfig is the configuration of the client used to communicate with the provider's target\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif override.ClientConfig != nil {\n\t\tcfg.ClientConfig = override.ClientConfig\n\t}\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n\tif len(override.Title) > 0 {\n\t\tcfg.Title = override.Title\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Teams\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tType       string    `json:\"@type\"`\n\tContext    string    `json:\"@context\"`\n\tThemeColor string    `json:\"themeColor\"`\n\tTitle      string    `json:\"title\"`\n\tText       string    `json:\"text\"`\n\tSections   []Section `json:\"sections,omitempty\"`\n}\n\ntype Section struct {\n\tActivityTitle string `json:\"activityTitle\"`\n\tText          string `json:\"text\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message, color string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tcolor = \"#36A64F\"\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t\tcolor = \"#DD0000\"\n\t}\n\tvar formattedConditionResults string\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \"&#x2705;\"\n\t\t} else {\n\t\t\tprefix = \"&#x274C;\"\n\t\t}\n\t\tformattedConditionResults += fmt.Sprintf(\"%s - `%s`<br/>\", prefix, conditionResult.Condition)\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \": \" + alertDescription\n\t}\n\tbody := Body{\n\t\tType:       \"MessageCard\",\n\t\tContext:    \"http://schema.org/extensions\",\n\t\tThemeColor: color,\n\t\tTitle:      cfg.Title,\n\t\tText:       message + description,\n\t}\n\tif len(body.Title) == 0 {\n\t\tbody.Title = \"&#x1F6A8; Gatus\"\n\t}\n\tif len(formattedConditionResults) > 0 {\n\t\tbody.Sections = append(body.Sections, Section{\n\t\t\tActivityTitle: \"Condition results\",\n\t\t\tText:          formattedConditionResults,\n\t\t})\n\t}\n\tbodyAsJSON, _ := json.Marshal(body)\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/teams/teams_test.go",
    "content": "package teams\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider integration key shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tNoConditions bool\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"@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\\\"}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"@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\\\"}]}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-with-no-conditions\",\n\t\t\tNoConditions: true,\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"@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\\\"}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif !scenario.NoConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t}\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{ConditionResults: conditionResults},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://example01.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://group-example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"webhook-url\": \"http://alert-example.com\"}},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://alert-example.com\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.WebhookURL != scenario.ExpectedOutput.WebhookURL {\n\t\t\t\tt.Errorf(\"expected webhook URL to be %s, got %s\", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/teamsworkflows/teamsworkflows.go",
    "content": "package teamsworkflows\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL string `yaml:\"webhook-url\"`\n\tTitle      string `yaml:\"title,omitempty\"` // Title of the message that will be sent\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n\tif len(override.Title) > 0 {\n\t\tcfg.Title = override.Title\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Teams\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\n// AdaptiveCardBody represents the structure of an Adaptive Card\ntype AdaptiveCardBody struct {\n\tType    string      `json:\"type\"`\n\tVersion string      `json:\"version\"`\n\tBody    []CardBody  `json:\"body\"`\n\tMSTeams MSTeamsBody `json:\"msteams\"`\n}\n\n// CardBody represents the body of the Adaptive Card\ntype CardBody struct {\n\tType      string       `json:\"type\"`\n\tText      string       `json:\"text,omitempty\"`\n\tWrap      bool         `json:\"wrap\"`\n\tSeparator bool         `json:\"separator,omitempty\"`\n\tSize      string       `json:\"size,omitempty\"`\n\tWeight    string       `json:\"weight,omitempty\"`\n\tItems     []CardBody   `json:\"items,omitempty\"`\n\tFacts     []Fact       `json:\"facts,omitempty\"`\n\tFactSet   *FactSetBody `json:\"factSet,omitempty\"`\n\tStyle     string       `json:\"style,omitempty\"`\n}\n\n// MSTeamsBody represents the msteams options\ntype MSTeamsBody struct {\n\tWidth string `json:\"width\"`\n}\n\n// FactSetBody represents the FactSet in the Adaptive Card\ntype FactSetBody struct {\n\tType  string `json:\"type\"`\n\tFacts []Fact `json:\"facts\"`\n}\n\n// Fact represents an individual fact in the FactSet\ntype Fact struct {\n\tTitle string `json:\"title\"`\n\tValue string `json:\"value\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message string\n\tvar themeColor string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for **%s** has been resolved after passing successfully %d time(s) in a row.\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tthemeColor = \"Good\" // green\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for **%s** has been triggered due to having failed %d time(s) in a row.\", ep.DisplayName(), alert.FailureThreshold)\n\t\tthemeColor = \"Attention\" // red\n\t}\n\n\t// Configure default title if it's not provided\n\ttitle := \"⛑️ Gatus\"\n\tif cfg.Title != \"\" {\n\t\ttitle = cfg.Title\n\t}\n\n\t// Build the facts from the condition results\n\tvar facts []Fact\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar key string\n\t\tif conditionResult.Success {\n\t\t\tkey = \"✅\"\n\t\t} else {\n\t\t\tkey = \"❌\"\n\t\t}\n\t\tfacts = append(facts, Fact{\n\t\t\tTitle: key,\n\t\t\tValue: conditionResult.Condition,\n\t\t})\n\t}\n\tvar description string\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tdescription = \"**Description**: \" + alertDescription\n\t}\n\tcardContent := AdaptiveCardBody{\n\t\tType:    \"AdaptiveCard\",\n\t\tVersion: \"1.4\", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024\n\t\tBody: []CardBody{\n\t\t\t{\n\t\t\t\tType:  \"Container\",\n\t\t\t\tStyle: themeColor,\n\t\t\t\tItems: []CardBody{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:  \"Container\",\n\t\t\t\t\t\tStyle: \"Default\",\n\t\t\t\t\t\tItems: []CardBody{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:   \"TextBlock\",\n\t\t\t\t\t\t\t\tText:   title,\n\t\t\t\t\t\t\t\tSize:   \"Medium\",\n\t\t\t\t\t\t\t\tWeight: \"Bolder\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType: \"TextBlock\",\n\t\t\t\t\t\t\t\tText: message,\n\t\t\t\t\t\t\t\tWrap: true,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType: \"TextBlock\",\n\t\t\t\t\t\t\t\tText: description,\n\t\t\t\t\t\t\t\tWrap: true,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:  \"FactSet\",\n\t\t\t\t\t\t\t\tFacts: facts,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tMSTeams: MSTeamsBody{\n\t\t\tWidth: \"Full\",\n\t\t},\n\t}\n\n\tattachment := map[string]interface{}{\n\t\t\"contentType\": \"application/vnd.microsoft.card.adaptive\",\n\t\t\"content\":     cardContent,\n\t}\n\n\tpayload := map[string]interface{}{\n\t\t\"type\":        \"message\",\n\t\t\"attachments\": []interface{}{attachment},\n\t}\n\n\tbodyAsJSON, _ := json.Marshal(payload)\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/teamsworkflows/teamsworkflows_test.go",
    "content": "package teamsworkflows\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tinvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"\"}}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid, got\", err.Error())\n\t}\n}\n\nfunc TestAlertProvider_ValidateWithOverride(t *testing.T) {\n\tproviderWithInvalidOverrideGroup := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideGroup.Validate(); err == nil {\n\t\tt.Error(\"provider Group shouldn't have been valid\")\n\t}\n\tproviderWithInvalidOverrideTo := AlertProvider{\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithInvalidOverrideTo.Validate(); err == nil {\n\t\tt.Error(\"provider integration key shouldn't have been valid\")\n\t}\n\tproviderWithValidOverride := AlertProvider{\n\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tGroup:  \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tif err := providerWithValidOverride.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{WebhookURL: \"http://example.com\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tNoConditions bool\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-with-no-conditions\",\n\t\t\tNoConditions: true,\n\t\t\tProvider:     AlertProvider{},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif !scenario.NoConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t}\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{ConditionResults: conditionResults},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-no-override-specify-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides:     nil,\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://example01.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://group-example.com\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{WebhookURL: \"http://example.com\"},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{WebhookURL: \"http://group-example.com\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup:     \"group\",\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"webhook-url\": \"http://alert-example.com\"}},\n\t\t\tExpectedOutput: Config{WebhookURL: \"http://alert-example.com\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.WebhookURL != scenario.ExpectedOutput.WebhookURL {\n\t\t\t\tt.Errorf(\"expected webhook URL to be %s, got %s\", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/telegram/telegram.go",
    "content": "package telegram\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst ApiURL = \"https://api.telegram.org\"\n\nvar (\n\tErrTokenNotSet            = errors.New(\"token not set\")\n\tErrIDNotSet               = errors.New(\"id not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tToken   string `yaml:\"token\"`\n\tID      string `yaml:\"id\"`\n\tTopicID string `yaml:\"topic-id,omitempty\"`\n\tApiUrl  string `yaml:\"api-url\"`\n\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.ApiUrl) == 0 {\n\t\tcfg.ApiUrl = ApiURL\n\t}\n\tif len(cfg.Token) == 0 {\n\t\treturn ErrTokenNotSet\n\t}\n\tif len(cfg.ID) == 0 {\n\t\treturn ErrIDNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif override.ClientConfig != nil {\n\t\tcfg.ClientConfig = override.ClientConfig\n\t}\n\tif len(override.Token) > 0 {\n\t\tcfg.Token = override.Token\n\t}\n\tif len(override.ID) > 0 {\n\t\tcfg.ID = override.ID\n\t}\n\tif len(override.TopicID) > 0 {\n\t\tcfg.TopicID = override.TopicID\n\t}\n\tif len(override.ApiUrl) > 0 {\n\t\tcfg.ApiUrl = override.ApiUrl\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Telegram\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of overrides that may be prioritized over the default configuration\n\tOverrides []*Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a configuration that may be prioritized over the default configuration\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\trequest, err := http.NewRequest(http.MethodPost, fmt.Sprintf(\"%s/bot%s/sendMessage\", cfg.ApiUrl, cfg.Token), buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\ntype Body struct {\n\tChatID    string `json:\"chat_id\"`\n\tText      string `json:\"text\"`\n\tParseMode string `json:\"parse_mode\"`\n\tTopicID   string `json:\"message_thread_id,omitempty\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {\n\tvar message string\n\tif resolved {\n\t\tmessage = 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)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for *%s* has been triggered:\\n—\\n    _healthcheck failed %d time(s) in a row_\\n—  \", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tvar formattedConditionResults string\n\tif len(result.ConditionResults) > 0 {\n\t\tformattedConditionResults = \"\\n*Condition results*\\n\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar prefix string\n\t\t\tif conditionResult.Success {\n\t\t\t\tprefix = \"✅\"\n\t\t\t} else {\n\t\t\t\tprefix = \"❌\"\n\t\t\t}\n\t\t\tformattedConditionResults += fmt.Sprintf(\"%s - `%s`\\n\", prefix, conditionResult.Condition)\n\t\t}\n\t}\n\tvar text string\n\tif len(alert.GetDescription()) > 0 {\n\t\ttext = fmt.Sprintf(\"⛑ *Gatus* \\n%s \\n*Description* \\n%s  \\n%s\", message, alert.GetDescription(), formattedConditionResults)\n\t} else {\n\t\ttext = fmt.Sprintf(\"⛑ *Gatus* \\n%s%s\", message, formattedConditionResults)\n\t}\n\tbodyAsJSON, _ := json.Marshal(Body{\n\t\tChatID:    cfg.ID,\n\t\tText:      text,\n\t\tParseMode: \"MARKDOWN\",\n\t\tTopicID:   cfg.TopicID,\n\t})\n\treturn bodyAsJSON\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/telegram/telegram_test.go",
    "content": "package telegram\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tt.Run(\"invalid-provider\", func(t *testing.T) {\n\t\tinvalidProvider := AlertProvider{DefaultConfig: Config{Token: \"\", ID: \"\"}}\n\t\tif err := invalidProvider.Validate(); err == nil {\n\t\t\tt.Error(\"provider shouldn't have been valid\")\n\t\t}\n\t})\n\tt.Run(\"valid-provider\", func(t *testing.T) {\n\t\tvalidProvider := AlertProvider{DefaultConfig: Config{Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\", ID: \"12345678\"}}\n\t\tif err := validProvider.Validate(); err != nil {\n\t\t\tt.Error(\"provider should've been valid\")\n\t\t}\n\t})\n\tt.Run(\"invalid-provider-override-nonexist-group\", func(t *testing.T) {\n\t\tinvalidProvider := AlertProvider{DefaultConfig: Config{Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\", ID: \"12345678\"}, Overrides: []*Override{{Config: Config{Token: \"token\", ID: \"id\"}}}}\n\t\tif err := invalidProvider.Validate(); err == nil {\n\t\t\tt.Error(\"provider shouldn't have been valid\")\n\t\t}\n\t})\n\tt.Run(\"invalid-provider-override-duplicate-group\", func(t *testing.T) {\n\t\tinvalidProvider := 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\"}}}}\n\t\tif err := invalidProvider.Validate(); err == nil {\n\t\t\tt.Error(\"provider shouldn't have been valid\")\n\t\t}\n\t})\n\tt.Run(\"valid-provider-with-overrides\", func(t *testing.T) {\n\t\tvalidProvider := AlertProvider{DefaultConfig: Config{Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\", ID: \"12345678\"}, Overrides: []*Override{{Group: \"group\", Config: Config{Token: \"token\", ID: \"id\"}}}}\n\t\tif err := validProvider.Validate(); err != nil {\n\t\t\tt.Error(\"provider should've been valid\")\n\t\t}\n\t})\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ID: \"123\", Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ID: \"123\", Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ID: \"123\", Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{ID: \"123\", Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tdescriptionWithLink := \"[link](https://example.org/)\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tNoConditions bool\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ID: \"123\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ID: \"123\"}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-with-no-conditions\",\n\t\t\tNoConditions: true,\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ID: \"123\"}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"send to topic\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ID: \"123\", TopicID: \"7\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered with link in description\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{ID: \"123\"}},\n\t\t\tAlert:        alert.Alert{Description: &descriptionWithLink, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"{\\\"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\\\"}\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif !scenario.NoConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t}\n\t\t\t}\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{ConditionResults: conditionResults},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n%s\\ngot:\\n%s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t\tout := make(map[string]interface{})\n\t\t\tif err := json.Unmarshal(body, &out); err != nil {\n\t\t\t\tt.Error(\"expected body to be valid JSON, got error:\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tt.Run(\"get-token-with-override\", func(t *testing.T) {\n\t\tprovider := AlertProvider{DefaultConfig: Config{Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\", ID: \"12345678\"}, Overrides: []*Override{{Group: \"group\", Config: Config{Token: \"groupToken\", ID: \"overrideID\"}}}}\n\t\tcfg, err := provider.GetConfig(\"group\", &alert.Alert{})\n\t\tif err != nil {\n\t\t\tt.Error(\"expected no error, got\", err)\n\t\t}\n\t\tif cfg.Token != \"groupToken\" {\n\t\t\tt.Error(\"token should have been 'groupToken'\")\n\t\t}\n\t\tif cfg.ID != \"overrideID\" {\n\t\t\tt.Error(\"id should have been 'overrideID'\")\n\t\t}\n\t})\n\tt.Run(\"get-default-token-with-overridden-id\", func(t *testing.T) {\n\t\tprovider := AlertProvider{DefaultConfig: Config{Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\", ID: \"12345678\"}, Overrides: []*Override{{Group: \"group\", Config: Config{ID: \"overrideID\"}}}}\n\t\tcfg, err := provider.GetConfig(\"group\", &alert.Alert{})\n\t\tif err != nil {\n\t\t\tt.Error(\"expected no error, got\", err)\n\t\t}\n\t\tif cfg.Token != provider.DefaultConfig.Token {\n\t\t\tt.Error(\"token should have been the default token\")\n\t\t}\n\t\tif cfg.ID != \"overrideID\" {\n\t\t\tt.Error(\"id should have been 'overrideID'\")\n\t\t}\n\t})\n\tt.Run(\"get-default-token-with-overridden-token\", func(t *testing.T) {\n\t\tprovider := AlertProvider{DefaultConfig: Config{Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\", ID: \"12345678\"}, Overrides: []*Override{{Group: \"group\", Config: Config{Token: \"groupToken\"}}}}\n\t\tcfg, err := provider.GetConfig(\"group\", &alert.Alert{})\n\t\tif err != nil {\n\t\t\tt.Error(\"expected no error, got\", err)\n\t\t}\n\t\tif cfg.Token != \"groupToken\" {\n\t\t\tt.Error(\"token should have been 'groupToken'\")\n\t\t}\n\t\tif cfg.ID != provider.DefaultConfig.ID {\n\t\t\tt.Error(\"id should have been the default id\")\n\t\t}\n\t})\n\tt.Run(\"get-default-token-with-overridden-token-and-alert-token-override\", func(t *testing.T) {\n\t\tprovider := AlertProvider{DefaultConfig: Config{Token: \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\", ID: \"12345678\"}, Overrides: []*Override{{Group: \"group\", Config: Config{Token: \"groupToken\"}}}}\n\t\talert := &alert.Alert{ProviderOverride: map[string]any{\"token\": \"alertToken\"}}\n\t\tcfg, err := provider.GetConfig(\"group\", alert)\n\t\tif err != nil {\n\t\t\tt.Error(\"expected no error, got\", err)\n\t\t}\n\t\tif cfg.Token != \"alertToken\" {\n\t\t\tt.Error(\"token should have been 'alertToken'\")\n\t\t}\n\t\tif cfg.ID != provider.DefaultConfig.ID {\n\t\t\tt.Error(\"id should have been the default id\")\n\t\t}\n\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\tif err = provider.ValidateOverrides(\"group\", alert); err != nil {\n\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "alerting/provider/twilio/twilio.go",
    "content": "package twilio\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrSIDNotSet   = errors.New(\"sid not set\")\n\tErrTokenNotSet = errors.New(\"token not set\")\n\tErrFromNotSet  = errors.New(\"from not set\")\n\tErrToNotSet    = errors.New(\"to not set\")\n)\n\ntype Config struct {\n\tSID   string `yaml:\"sid\"`\n\tToken string `yaml:\"token\"`\n\tFrom  string `yaml:\"from\"`\n\tTo    string `yaml:\"to\"`\n\n\t// TODO in v6.0.0: Rename this to text-triggered\n\tTextTwilioTriggered string `yaml:\"text-twilio-triggered,omitempty\"` // String used in the SMS body and subject (optional)\n\t// TODO in v6.0.0: Rename this to text-resolved\n\tTextTwilioResolved string `yaml:\"text-twilio-resolved,omitempty\"` // String used in the SMS body and subject (optional)\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.SID) == 0 {\n\t\treturn ErrSIDNotSet\n\t}\n\tif len(cfg.Token) == 0 {\n\t\treturn ErrTokenNotSet\n\t}\n\tif len(cfg.From) == 0 {\n\t\treturn ErrFromNotSet\n\t}\n\tif len(cfg.To) == 0 {\n\t\treturn ErrToNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.SID) > 0 {\n\t\tcfg.SID = override.SID\n\t}\n\tif len(override.Token) > 0 {\n\t\tcfg.Token = override.Token\n\t}\n\tif len(override.From) > 0 {\n\t\tcfg.From = override.From\n\t}\n\tif len(override.To) > 0 {\n\t\tcfg.To = override.To\n\t}\n\tif len(override.TextTwilioTriggered) > 0 {\n\t\tcfg.TextTwilioTriggered = override.TextTwilioTriggered\n\t}\n\tif len(override.TextTwilioResolved) > 0 {\n\t\tcfg.TextTwilioResolved = override.TextTwilioResolved\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Twilio\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))\n\trequest, err := http.NewRequest(http.MethodPost, fmt.Sprintf(\"https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json\", cfg.SID), buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\trequest.Header.Set(\"Authorization\", fmt.Sprintf(\"Basic %s\", base64.StdEncoding.EncodeToString([]byte(cfg.SID+\":\"+cfg.Token))))\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn err\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {\n\tvar message string\n\tif resolved {\n\t\tif len(cfg.TextTwilioResolved) > 0 {\n\t\t\t// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats\n\t\t\tmessage = cfg.TextTwilioResolved\n\t\t\tmessage = strings.Replace(message, \"{endpoint}\", ep.DisplayName(), 1)\n\t\t\tmessage = strings.Replace(message, \"{description}\", alert.GetDescription(), 1)\n\t\t\tmessage = strings.Replace(message, \"[ENDPOINT]\", ep.DisplayName(), 1)\n\t\t\tmessage = strings.Replace(message, \"[ALERT_DESCRIPTION]\", alert.GetDescription(), 1)\n\t\t} else {\n\t\t\tmessage = fmt.Sprintf(\"RESOLVED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t\t}\n\t} else {\n\t\tif len(cfg.TextTwilioTriggered) > 0 {\n\t\t\t// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats\n\t\t\tmessage = cfg.TextTwilioTriggered\n\t\t\tmessage = strings.Replace(message, \"{endpoint}\", ep.DisplayName(), 1)\n\t\t\tmessage = strings.Replace(message, \"{description}\", alert.GetDescription(), 1)\n\t\t\tmessage = strings.Replace(message, \"[ENDPOINT]\", ep.DisplayName(), 1)\n\t\t\tmessage = strings.Replace(message, \"[ALERT_DESCRIPTION]\", alert.GetDescription(), 1)\n\t\t} else {\n\t\t\tmessage = fmt.Sprintf(\"TRIGGERED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t\t}\n\t}\n\treturn url.Values{\n\t\t\"To\":   {cfg.To},\n\t\t\"From\": {cfg.From},\n\t\t\"Body\": {message},\n\t}.Encode()\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/twilio/twilio_test.go",
    "content": "package twilio\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestTwilioAlertProvider_IsValid(t *testing.T) {\n\tinvalidProvider := AlertProvider{}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tSID:   \"1\",\n\t\t\tToken: \"1\",\n\t\t\tFrom:  \"1\",\n\t\t\tTo:    \"1\",\n\t\t},\n\t}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName:     \"triggered\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"triggered-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\"}},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"resolved-error\",\n\t\t\tProvider: AlertProvider{DefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\"}},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName         string\n\t\tProvider     AlertProvider\n\t\tAlert        alert.Alert\n\t\tResolved     bool\n\t\tExpectedBody string\n\t}{\n\t\t{\n\t\t\tName:         \"triggered\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\"}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-old-placeholders\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\", TextTwilioTriggered: \"Alert: {endpoint} - {description}\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4\",\n\t\t},\n\t\t{\n\t\t\tName:         \"triggered-with-new-placeholders\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\", TextTwilioTriggered: \"Alert: [ENDPOINT] - [ALERT_DESCRIPTION]\"}},\n\t\t\tAlert:        alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     false,\n\t\t\tExpectedBody: \"Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4\",\n\t\t},\n\t\t{\n\t\t\tName:         \"resolved-with-mixed-placeholders\",\n\t\t\tProvider:     AlertProvider{DefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\", TextTwilioResolved: \"Resolved: {endpoint} and [ENDPOINT] - {description} and [ALERT_DESCRIPTION]\"}},\n\t\t\tAlert:        alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:     true,\n\t\t\tExpectedBody: \"Body=Resolved%3A+endpoint-name+and+endpoint-name+-+description-2+and+description-2&From=3&To=4\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tbody := scenario.Provider.buildRequestBody(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif body != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.ExpectedBody, body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{},\n\t\t\tExpectedOutput: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\"},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{SID: \"1\", Token: \"2\", From: \"3\", To: \"4\"},\n\t\t\t},\n\t\t\tInputAlert:     alert.Alert{ProviderOverride: map[string]any{\"sid\": \"5\", \"token\": \"6\", \"from\": \"7\", \"to\": \"8\"}},\n\t\t\tExpectedOutput: Config{SID: \"5\", Token: \"6\", From: \"7\", To: \"8\"},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(\"\", &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected no error, got:\", err.Error())\n\t\t\t}\n\t\t\tif got.SID != scenario.ExpectedOutput.SID {\n\t\t\t\tt.Errorf(\"expected SID to be %s, got %s\", scenario.ExpectedOutput.SID, got.SID)\n\t\t\t}\n\t\t\tif got.Token != scenario.ExpectedOutput.Token {\n\t\t\t\tt.Errorf(\"expected token to be %s, got %s\", scenario.ExpectedOutput.Token, got.Token)\n\t\t\t}\n\t\t\tif got.From != scenario.ExpectedOutput.From {\n\t\t\t\tt.Errorf(\"expected from to be %s, got %s\", scenario.ExpectedOutput.From, got.From)\n\t\t\t}\n\t\t\tif got.To != scenario.ExpectedOutput.To {\n\t\t\t\tt.Errorf(\"expected to to be %s, got %s\", scenario.ExpectedOutput.To, got.To)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(\"\", &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "alerting/provider/vonage/vonage.go",
    "content": "package vonage\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst ApiURL = \"https://rest.nexmo.com/sms/json\"\n\nvar (\n\tErrAPIKeyNotSet           = errors.New(\"api-key not set\")\n\tErrAPISecretNotSet        = errors.New(\"api-secret not set\")\n\tErrFromNotSet             = errors.New(\"from not set\")\n\tErrToNotSet               = errors.New(\"to not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tAPIKey    string   `yaml:\"api-key\"`\n\tAPISecret string   `yaml:\"api-secret\"`\n\tFrom      string   `yaml:\"from\"`\n\tTo        []string `yaml:\"to\"`\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.APIKey) == 0 {\n\t\treturn ErrAPIKeyNotSet\n\t}\n\tif len(cfg.APISecret) == 0 {\n\t\treturn ErrAPISecretNotSet\n\t}\n\tif len(cfg.From) == 0 {\n\t\treturn ErrFromNotSet\n\t}\n\tif len(cfg.To) == 0 {\n\t\treturn ErrToNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.APIKey) > 0 {\n\t\tcfg.APIKey = override.APIKey\n\t}\n\tif len(override.APISecret) > 0 {\n\t\tcfg.APISecret = override.APISecret\n\t}\n\tif len(override.From) > 0 {\n\t\tcfg.From = override.From\n\t}\n\tif len(override.To) > 0 {\n\t\tcfg.To = override.To\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Vonage\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tmessage := provider.buildMessage(cfg, ep, alert, result, resolved)\n\n\t// Send SMS to each recipient\n\tfor _, recipient := range cfg.To {\n\t\tif err := provider.sendSMS(cfg, recipient, message); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// sendSMS sends an individual SMS message\nfunc (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {\n\tdata := url.Values{}\n\tdata.Set(\"api_key\", cfg.APIKey)\n\tdata.Set(\"api_secret\", cfg.APISecret)\n\tdata.Set(\"from\", cfg.From)\n\tdata.Set(\"to\", to)\n\tdata.Set(\"text\", message)\n\trequest, err := http.NewRequest(http.MethodPost, ApiURL, strings.NewReader(data.Encode()))\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\t// Read response body once and use it for both error handling and JSON processing\n\tbody, err := io.ReadAll(response.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif response.StatusCode >= 400 {\n\t\treturn fmt.Errorf(\"call to vonage alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\t// Check response for errors in messages array\n\tvar vonageResponse Response\n\tif err := json.Unmarshal(body, &vonageResponse); err != nil {\n\t\treturn err\n\t}\n\t// Check if any message failed\n\tfor _, msg := range vonageResponse.Messages {\n\t\tif msg.Status != \"0\" {\n\t\t\treturn fmt.Errorf(\"vonage SMS failed with status %s: %s\", msg.Status, msg.ErrorText)\n\t\t}\n\t}\n\treturn nil\n}\n\ntype Response struct {\n\tMessageCount string    `json:\"message-count\"`\n\tMessages     []Message `json:\"messages\"`\n}\n\ntype Message struct {\n\tTo               string `json:\"to\"`\n\tMessageID        string `json:\"message-id\"`\n\tStatus           string `json:\"status\"`\n\tErrorText        string `json:\"error-text\"`\n\tRemainingBalance string `json:\"remaining-balance\"`\n\tMessagePrice     string `json:\"message-price\"`\n\tNetwork          string `json:\"network\"`\n}\n\n// buildMessage builds the SMS message content\nfunc (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {\n\tif resolved {\n\t\treturn fmt.Sprintf(\"RESOLVED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t} else {\n\t\treturn fmt.Sprintf(\"TRIGGERED: %s - %s\", ep.DisplayName(), alert.GetDescription())\n\t}\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/vonage/vonage_test.go",
    "content": "package vonage\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestVonageAlertProvider_IsValid(t *testing.T) {\n\tinvalidProvider := AlertProvider{}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n\tvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey:    \"test-key\",\n\t\t\tAPISecret: \"test-secret\",\n\t\t\tFrom:      \"Gatus\",\n\t\t\tTo:        []string{\"+1234567890\"},\n\t\t},\n\t}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestVonageAlertProvider_IsValidWithOverride(t *testing.T) {\n\tvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey:    \"test-key\",\n\t\t\tAPISecret: \"test-secret\",\n\t\t\tFrom:      \"Gatus\",\n\t\t\tTo:        []string{\"+1234567890\"},\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tGroup: \"test-group\",\n\t\t\t\tConfig: Config{\n\t\t\t\t\tAPIKey:    \"override-key\",\n\t\t\t\t\tAPISecret: \"override-secret\",\n\t\t\t\t\tFrom:      \"Override\",\n\t\t\t\t\tTo:        []string{\"+9876543210\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tif err := validProvider.Validate(); err != nil {\n\t\tt.Error(\"provider should've been valid\")\n\t}\n}\n\nfunc TestVonageAlertProvider_IsNotValidWithInvalidOverrideGroup(t *testing.T) {\n\tinvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey:    \"test-key\",\n\t\t\tAPISecret: \"test-secret\",\n\t\t\tFrom:      \"Gatus\",\n\t\t\tTo:        []string{\"+1234567890\"},\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tGroup: \"\",\n\t\t\t\tConfig: Config{\n\t\t\t\t\tAPIKey:    \"override-key\",\n\t\t\t\t\tAPISecret: \"override-secret\",\n\t\t\t\t\tFrom:      \"Override\",\n\t\t\t\t\tTo:        []string{\"+9876543210\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n}\n\nfunc TestVonageAlertProvider_IsNotValidWithDuplicateOverrideGroup(t *testing.T) {\n\tinvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey:    \"test-key\",\n\t\t\tAPISecret: \"test-secret\",\n\t\t\tFrom:      \"Gatus\",\n\t\t\tTo:        []string{\"+1234567890\"},\n\t\t},\n\t\tOverrides: []Override{\n\t\t\t{\n\t\t\t\tGroup: \"test-group\",\n\t\t\t\tConfig: Config{\n\t\t\t\t\tAPIKey:    \"override-key1\",\n\t\t\t\t\tAPISecret: \"override-secret1\",\n\t\t\t\t\tFrom:      \"Override1\",\n\t\t\t\t\tTo:        []string{\"+9876543210\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tGroup: \"test-group\",\n\t\t\t\tConfig: Config{\n\t\t\t\t\tAPIKey:    \"override-key2\",\n\t\t\t\t\tAPISecret: \"override-secret2\",\n\t\t\t\t\tFrom:      \"Override2\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n}\n\nfunc TestVonageAlertProvider_IsValidWithInvalidFrom(t *testing.T) {\n\tinvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey:    \"test-key\",\n\t\t\tAPISecret: \"test-secret\",\n\t\t\tFrom:      \"\",\n\t\t\tTo:        []string{\"+1234567890\"},\n\t\t},\n\t}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n}\n\nfunc TestVonageAlertProvider_IsValidWithInvalidTo(t *testing.T) {\n\tinvalidProvider := AlertProvider{\n\t\tDefaultConfig: Config{\n\t\t\tAPIKey:    \"test-key\",\n\t\t\tAPISecret: \"test-secret\",\n\t\t\tFrom:      \"Gatus\",\n\t\t\tTo:        []string{},\n\t\t},\n\t}\n\tif err := invalidProvider.Validate(); err == nil {\n\t\tt.Error(\"provider shouldn't have been valid\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName             string\n\t\tProvider         AlertProvider\n\t\tAlert            alert.Alert\n\t\tResolved         bool\n\t\tMockRoundTripper test.MockRoundTripper\n\t\tExpectedError    bool\n\t}{\n\t\t{\n\t\t\tName: \"triggered\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tBody:       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\"}]}`)),\n\t\t\t\t}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName: \"triggered-error-status-code\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName: \"triggered-error-vonage-response\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tBody:       io.NopCloser(strings.NewReader(`{\"message-count\":\"1\",\"messages\":[{\"to\":\"+1234567890\",\"message-id\":\"\",\"status\":\"2\",\"error-text\":\"Missing from param\"}]}`)),\n\t\t\t\t}\n\t\t\t}),\n\t\t\tExpectedError: true,\n\t\t},\n\t\t{\n\t\t\tName: \"resolved\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tAlert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: true,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tBody:       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\"}]}`)),\n\t\t\t\t}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t\t{\n\t\t\tName: \"multiple-recipients\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\", \"+0987654321\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tAlert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved: false,\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tBody:       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\"}]}`)),\n\t\t\t\t}\n\t\t\t}),\n\t\t\tExpectedError: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})\n\t\t\terr := scenario.Provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif scenario.ExpectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.ExpectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildMessage(t *testing.T) {\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tName            string\n\t\tProvider        AlertProvider\n\t\tAlert           alert.Alert\n\t\tResolved        bool\n\t\tExpectedMessage string\n\t}{\n\t\t{\n\t\t\tName: \"triggered\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tAlert:           alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        false,\n\t\t\tExpectedMessage: \"TRIGGERED: endpoint-name - description-1\",\n\t\t},\n\t\t{\n\t\t\tName: \"resolved\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tAlert:           alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tResolved:        true,\n\t\t\tExpectedMessage: \"RESOLVED: endpoint-name - description-2\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tmessage := scenario.Provider.buildMessage(\n\t\t\t\t&scenario.Provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.Alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.Resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.Resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.Resolved,\n\t\t\t)\n\t\t\tif message != scenario.ExpectedMessage {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.ExpectedMessage, message)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-override-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup: \"test-group\",\n\t\t\t\t\t\tConfig: Config{\n\t\t\t\t\t\t\tAPIKey:    \"group-override-key\",\n\t\t\t\t\t\t\tAPISecret: \"group-override-secret\",\n\t\t\t\t\t\t\tFrom:      \"GroupOverride\",\n\t\t\t\t\t\t\tTo:        []string{\"+9876543210\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"test-group\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tAPIKey:    \"group-override-key\",\n\t\t\t\tAPISecret: \"group-override-secret\",\n\t\t\t\tFrom:      \"GroupOverride\",\n\t\t\t\tTo:        []string{\"+9876543210\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-partial\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup: \"test-group\",\n\t\t\t\t\t\tConfig: Config{\n\t\t\t\t\t\t\tTo: []string{\"+9876543210\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"test-group\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\tTo:        []string{\"+9876543210\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{ProviderOverride: map[string]any{\n\t\t\t\t\"api-key\":    \"override-key\",\n\t\t\t\t\"api-secret\": \"override-secret\",\n\t\t\t\t\"from\":       \"Override\",\n\t\t\t\t\"to\":         []string{\"+9876543210\"},\n\t\t\t}},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tAPIKey:    \"override-key\",\n\t\t\t\tAPISecret: \"override-secret\",\n\t\t\t\tFrom:      \"Override\",\n\t\t\t\tTo:        []string{\"+9876543210\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-both-group-and-alert-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup: \"test-group\",\n\t\t\t\t\t\tConfig: Config{\n\t\t\t\t\t\t\tAPIKey: \"group-override-key\",\n\t\t\t\t\t\t\tFrom:   \"GroupOverride\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"test-group\",\n\t\t\tInputAlert: alert.Alert{ProviderOverride: map[string]any{\n\t\t\t\t\"api-secret\": \"alert-override-secret\",\n\t\t\t\t\"to\":         []string{\"+9876543210\"},\n\t\t\t}},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tAPIKey:    \"group-override-key\",\n\t\t\t\tAPISecret: \"alert-override-secret\",\n\t\t\t\tFrom:      \"GroupOverride\",\n\t\t\t\tTo:        []string{\"+9876543210\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-no-match\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup: \"different-group\",\n\t\t\t\t\t\tConfig: Config{\n\t\t\t\t\t\t\tAPIKey: \"group-override-key\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"test-group\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\tFrom:      \"Gatus\",\n\t\t\t\tTo:        []string{\"+1234567890\"},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected no error, got:\", err.Error())\n\t\t\t}\n\t\t\tif got.APIKey != scenario.ExpectedOutput.APIKey {\n\t\t\t\tt.Errorf(\"expected APIKey to be %s, got %s\", scenario.ExpectedOutput.APIKey, got.APIKey)\n\t\t\t}\n\t\t\tif got.APISecret != scenario.ExpectedOutput.APISecret {\n\t\t\t\tt.Errorf(\"expected APISecret to be %s, got %s\", scenario.ExpectedOutput.APISecret, got.APISecret)\n\t\t\t}\n\t\t\tif got.From != scenario.ExpectedOutput.From {\n\t\t\t\tt.Errorf(\"expected From to be %s, got %s\", scenario.ExpectedOutput.From, got.From)\n\t\t\t}\n\t\t\tif len(got.To) != len(scenario.ExpectedOutput.To) {\n\t\t\t\tt.Errorf(\"expected To to have length %d, got %d\", len(scenario.ExpectedOutput.To), len(got.To))\n\t\t\t} else {\n\t\t\t\tfor i, to := range got.To {\n\t\t\t\t\tif to != scenario.ExpectedOutput.To[i] {\n\t\t\t\t\t\tt.Errorf(\"expected To[%d] to be %s, got %s\", i, scenario.ExpectedOutput.To[i], to)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n"
  },
  {
    "path": "alerting/provider/webex/webex.go",
    "content": "package webex\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL string `yaml:\"webhook-url\"` // Webex Teams webhook URL\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Webex\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbody, err := provider.buildRequestBody(ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(body)\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to webex alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tRoomID   string `json:\"roomId,omitempty\"`\n\tText     string `json:\"text,omitempty\"`\n\tMarkdown string `json:\"markdown\"`\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"✅ **RESOLVED**: %s\\n\\nAlert has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"🚨 **ALERT**: %s\\n\\nEndpoint has failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tmessage += fmt.Sprintf(\"\\n\\n**Description**: %s\", alertDescription)\n\t}\n\tif len(result.ConditionResults) > 0 {\n\t\tmessage += \"\\n\\n**Condition Results:**\"\n\t\tfor _, conditionResult := range result.ConditionResults {\n\t\t\tvar status string\n\t\t\tif conditionResult.Success {\n\t\t\t\tstatus = \"✅\"\n\t\t\t} else {\n\t\t\t\tstatus = \"❌\"\n\t\t\t}\n\t\t\tmessage += fmt.Sprintf(\"\\n- %s `%s`\", status, conditionResult.Condition)\n\t\t}\n\t}\n\tbody := Body{\n\t\tMarkdown: message,\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/webex/webex_test.go",
    "content": "package webex\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://webexapis.com/v1/webhooks/incoming/123\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-webhook-url\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{}},\n\t\t\texpected: ErrWebhookURLNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://webexapis.com/v1/webhooks/incoming/123\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"markdown\"] == nil {\n\t\t\t\t\tt.Error(\"expected 'markdown' field in request body\")\n\t\t\t\t}\n\t\t\t\tmarkdown := body[\"markdown\"].(string)\n\t\t\t\tif !strings.Contains(markdown, \"ALERT\") {\n\t\t\t\t\tt.Errorf(\"expected markdown to contain 'ALERT', got %s\", markdown)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(markdown, \"failed 3 time(s)\") {\n\t\t\t\t\tt.Errorf(\"expected markdown to contain failure count, got %s\", markdown)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://webexapis.com/v1/webhooks/incoming/123\"}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tmarkdown := body[\"markdown\"].(string)\n\t\t\t\tif !strings.Contains(markdown, \"RESOLVED\") {\n\t\t\t\t\tt.Errorf(\"expected markdown to contain 'RESOLVED', got %s\", markdown)\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://webexapis.com/v1/webhooks/incoming/123\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}"
  },
  {
    "path": "alerting/provider/zapier/zapier.go",
    "content": "package zapier\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrWebhookURLNotSet       = errors.New(\"webhook-url not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tWebhookURL string `yaml:\"webhook-url\"` // Zapier webhook URL\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.WebhookURL) == 0 {\n\t\treturn ErrWebhookURLNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.WebhookURL) > 0 {\n\t\tcfg.WebhookURL = override.WebhookURL\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Zapier\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbody, err := provider.buildRequestBody(ep, alert, result, resolved)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBuffer(body)\n\trequest, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode >= 400 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to zapier alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\ntype Body struct {\n\tAlertType        string            `json:\"alert_type\"`\n\tStatus           string            `json:\"status\"`\n\tEndpoint         string            `json:\"endpoint\"`\n\tGroup            string            `json:\"group,omitempty\"`\n\tMessage          string            `json:\"message\"`\n\tDescription      string            `json:\"description,omitempty\"`\n\tTimestamp        string            `json:\"timestamp\"`\n\tSuccessThreshold int               `json:\"success_threshold,omitempty\"`\n\tFailureThreshold int               `json:\"failure_threshold,omitempty\"`\n\tConditionResults []*endpoint.ConditionResult `json:\"condition_results,omitempty\"`\n\tTotalConditions  int               `json:\"total_conditions\"`\n\tPassedConditions int               `json:\"passed_conditions\"`\n\tFailedConditions int               `json:\"failed_conditions\"`\n}\n\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {\n\tvar alertType, status, message string\n\tvar successThreshold, failureThreshold int\n\tif resolved {\n\t\talertType = \"resolved\"\n\t\tstatus = \"ok\"\n\t\tmessage = fmt.Sprintf(\"Alert for %s has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t\tsuccessThreshold = alert.SuccessThreshold\n\t} else {\n\t\talertType = \"triggered\"\n\t\tstatus = \"critical\"\n\t\tmessage = fmt.Sprintf(\"Alert for %s has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t\tfailureThreshold = alert.FailureThreshold\n\t}\n\t// Process condition results\n\tpassedConditions := 0\n\tfailedConditions := 0\n\tfor _, cr := range result.ConditionResults {\n\t\tif cr.Success {\n\t\t\tpassedConditions++\n\t\t} else {\n\t\t\tfailedConditions++\n\t\t}\n\t}\n\tbody := Body{\n\t\tAlertType:        alertType,\n\t\tStatus:           status,\n\t\tEndpoint:         ep.DisplayName(),\n\t\tGroup:            ep.Group,\n\t\tMessage:          message,\n\t\tDescription:      alert.GetDescription(),\n\t\tTimestamp:        time.Now().Format(time.RFC3339),\n\t\tSuccessThreshold: successThreshold,\n\t\tFailureThreshold: failureThreshold,\n\t\tConditionResults: result.ConditionResults,\n\t\tTotalConditions:  len(result.ConditionResults),\n\t\tPassedConditions: passedConditions,\n\t\tFailedConditions: failedConditions,\n\t}\n\tbodyAsJSON, err := json.Marshal(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn bodyAsJSON, nil\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/zapier/zapier_test.go",
    "content": "package zapier\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\tprovider AlertProvider\n\t\texpected error\n\t}{\n\t\t{\n\t\t\tname:     \"valid\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://hooks.zapier.com/hooks/catch/123456/abcdef/\"}},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid-webhook-url\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{}},\n\t\t\texpected: ErrWebhookURLNotSet,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.provider.Validate()\n\t\t\tif err != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tfirstDescription := \"description-1\"\n\tsecondDescription := \"description-2\"\n\tscenarios := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname:     \"triggered\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://hooks.zapier.com/hooks/catch/123456/abcdef/\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tif r.Host != \"hooks.zapier.com\" {\n\t\t\t\t\tt.Errorf(\"expected host hooks.zapier.com, got %s\", r.Host)\n\t\t\t\t}\n\t\t\t\tif r.URL.Path != \"/hooks/catch/123456/abcdef/\" {\n\t\t\t\t\tt.Errorf(\"expected path /hooks/catch/123456/abcdef/, got %s\", r.URL.Path)\n\t\t\t\t}\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"alert_type\"] != \"triggered\" {\n\t\t\t\t\tt.Errorf(\"expected alert_type to be 'triggered', got %v\", body[\"alert_type\"])\n\t\t\t\t}\n\t\t\t\tif body[\"status\"] != \"critical\" {\n\t\t\t\t\tt.Errorf(\"expected status to be 'critical', got %v\", body[\"status\"])\n\t\t\t\t}\n\t\t\t\tif body[\"endpoint\"] != \"endpoint-name\" {\n\t\t\t\t\tt.Errorf(\"expected endpoint to be 'endpoint-name', got %v\", body[\"endpoint\"])\n\t\t\t\t}\n\t\t\t\tmessage := body[\"message\"].(string)\n\t\t\t\tif !strings.Contains(message, \"Alert\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain 'Alert', got %s\", message)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(message, \"failed 3 time(s)\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain failure count, got %s\", message)\n\t\t\t\t}\n\t\t\t\tif body[\"description\"] != firstDescription {\n\t\t\t\t\tt.Errorf(\"expected description to be '%s', got %v\", firstDescription, body[\"description\"])\n\t\t\t\t}\n\t\t\t\tconditionResults := body[\"condition_results\"].([]interface{})\n\t\t\t\tif len(conditionResults) != 2 {\n\t\t\t\t\tt.Errorf(\"expected 2 condition results, got %d\", len(conditionResults))\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"resolved\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://hooks.zapier.com/hooks/catch/123456/abcdef/\"}},\n\t\t\talert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\tbody := make(map[string]interface{})\n\t\t\t\tjson.NewDecoder(r.Body).Decode(&body)\n\t\t\t\tif body[\"alert_type\"] != \"resolved\" {\n\t\t\t\t\tt.Errorf(\"expected alert_type to be 'resolved', got %v\", body[\"alert_type\"])\n\t\t\t\t}\n\t\t\t\tif body[\"status\"] != \"ok\" {\n\t\t\t\t\tt.Errorf(\"expected status to be 'ok', got %v\", body[\"status\"])\n\t\t\t\t}\n\t\t\t\tmessage := body[\"message\"].(string)\n\t\t\t\tif !strings.Contains(message, \"resolved\") {\n\t\t\t\t\tt.Errorf(\"expected message to contain 'resolved', got %s\", message)\n\t\t\t\t}\n\t\t\t\tif body[\"description\"] != secondDescription {\n\t\t\t\t\tt.Errorf(\"expected description to be '%s', got %v\", secondDescription, body[\"description\"])\n\t\t\t\t}\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"error-response\",\n\t\t\tprovider: AlertProvider{DefaultConfig: Config{WebhookURL: \"https://hooks.zapier.com/hooks/catch/123456/abcdef/\"}},\n\t\t\talert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})\n\t\t\terr := scenario.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-name\"},\n\t\t\t\t&scenario.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: scenario.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: scenario.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tscenario.resolved,\n\t\t\t)\n\t\t\tif scenario.expectedError && err == nil {\n\t\t\t\tt.Error(\"expected error, got none\")\n\t\t\t}\n\t\t\tif !scenario.expectedError && err != nil {\n\t\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"expected default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"expected default alert to be nil\")\n\t}\n}"
  },
  {
    "path": "alerting/provider/zulip/zulip.go",
    "content": "package zulip\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nvar (\n\tErrBotEmailNotSet         = errors.New(\"bot-email not set\")\n\tErrBotAPIKeyNotSet        = errors.New(\"bot-api-key not set\")\n\tErrDomainNotSet           = errors.New(\"domain not set\")\n\tErrChannelIDNotSet        = errors.New(\"channel-id not set\")\n\tErrDuplicateGroupOverride = errors.New(\"duplicate group override\")\n)\n\ntype Config struct {\n\tBotEmail  string `yaml:\"bot-email\"`   // Email of the bot user\n\tBotAPIKey string `yaml:\"bot-api-key\"` // API key of the bot user\n\tDomain    string `yaml:\"domain\"`      // Domain of the Zulip server\n\tChannelID string `yaml:\"channel-id\"`  // ID of the channel to send the message to\n}\n\nfunc (cfg *Config) Validate() error {\n\tif len(cfg.BotEmail) == 0 {\n\t\treturn ErrBotEmailNotSet\n\t}\n\tif len(cfg.BotAPIKey) == 0 {\n\t\treturn ErrBotAPIKeyNotSet\n\t}\n\tif len(cfg.Domain) == 0 {\n\t\treturn ErrDomainNotSet\n\t}\n\tif len(cfg.ChannelID) == 0 {\n\t\treturn ErrChannelIDNotSet\n\t}\n\treturn nil\n}\n\nfunc (cfg *Config) Merge(override *Config) {\n\tif len(override.BotEmail) > 0 {\n\t\tcfg.BotEmail = override.BotEmail\n\t}\n\tif len(override.BotAPIKey) > 0 {\n\t\tcfg.BotAPIKey = override.BotAPIKey\n\t}\n\tif len(override.Domain) > 0 {\n\t\tcfg.Domain = override.Domain\n\t}\n\tif len(override.ChannelID) > 0 {\n\t\tcfg.ChannelID = override.ChannelID\n\t}\n}\n\n// AlertProvider is the configuration necessary for sending an alert using Zulip\ntype AlertProvider struct {\n\tDefaultConfig Config `yaml:\",inline\"`\n\n\t// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type\n\tDefaultAlert *alert.Alert `yaml:\"default-alert,omitempty\"`\n\n\t// Overrides is a list of Override that may be prioritized over the default configuration\n\tOverrides []Override `yaml:\"overrides,omitempty\"`\n}\n\n// Override is a case under which the default integration is overridden\ntype Override struct {\n\tGroup  string `yaml:\"group\"`\n\tConfig `yaml:\",inline\"`\n}\n\n// Validate the provider's configuration\nfunc (provider *AlertProvider) Validate() error {\n\tregisteredGroups := make(map[string]bool)\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == \"\" {\n\t\t\t\treturn ErrDuplicateGroupOverride\n\t\t\t}\n\t\t\tregisteredGroups[override.Group] = true\n\t\t}\n\t}\n\treturn provider.DefaultConfig.Validate()\n}\n\n// Send an alert using the provider\nfunc (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {\n\tcfg, err := provider.GetConfig(ep.Group, alert)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbuffer := bytes.NewBufferString(provider.buildRequestBody(cfg, ep, alert, result, resolved))\n\tzulipEndpoint := fmt.Sprintf(\"https://%s/api/v1/messages\", cfg.Domain)\n\trequest, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)\n\tif err != nil {\n\t\treturn err\n\t}\n\trequest.SetBasicAuth(cfg.BotEmail, cfg.BotAPIKey)\n\trequest.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\trequest.Header.Set(\"User-Agent\", \"Gatus\")\n\tresponse, err := client.GetHTTPClient(nil).Do(request)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode > 399 {\n\t\tbody, _ := io.ReadAll(response.Body)\n\t\treturn fmt.Errorf(\"call to provider alert returned status code %d: %s\", response.StatusCode, string(body))\n\t}\n\treturn nil\n}\n\n// buildRequestBody builds the request body for the provider\nfunc (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {\n\tvar message string\n\tif resolved {\n\t\tmessage = fmt.Sprintf(\"An alert for **%s** has been resolved after passing successfully %d time(s) in a row\", ep.DisplayName(), alert.SuccessThreshold)\n\t} else {\n\t\tmessage = fmt.Sprintf(\"An alert for **%s** has been triggered due to having failed %d time(s) in a row\", ep.DisplayName(), alert.FailureThreshold)\n\t}\n\tif alertDescription := alert.GetDescription(); len(alertDescription) > 0 {\n\t\tmessage += \"\\n> \" + alertDescription + \"\\n\"\n\t}\n\tfor _, conditionResult := range result.ConditionResults {\n\t\tvar prefix string\n\t\tif conditionResult.Success {\n\t\t\tprefix = \":check:\"\n\t\t} else {\n\t\t\tprefix = \":cross_mark:\"\n\t\t}\n\t\tmessage += fmt.Sprintf(\"\\n%s - `%s`\", prefix, conditionResult.Condition)\n\t}\n\treturn url.Values{\n\t\t\"type\":    {\"channel\"},\n\t\t\"to\":      {cfg.ChannelID},\n\t\t\"topic\":   {\"Gatus\"},\n\t\t\"content\": {message},\n\t}.Encode()\n}\n\n// GetDefaultAlert returns the provider's default alert configuration\nfunc (provider *AlertProvider) GetDefaultAlert() *alert.Alert {\n\treturn provider.DefaultAlert\n}\n\n// GetConfig returns the configuration for the provider with the overrides applied\nfunc (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {\n\tcfg := provider.DefaultConfig\n\t// Handle group overrides\n\tif provider.Overrides != nil {\n\t\tfor _, override := range provider.Overrides {\n\t\t\tif group == override.Group {\n\t\t\t\tcfg.Merge(&override.Config)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\t// Handle alert overrides\n\tif len(alert.ProviderOverride) != 0 {\n\t\toverrideConfig := Config{}\n\t\tif err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcfg.Merge(&overrideConfig)\n\t}\n\t// Validate the configuration\n\terr := cfg.Validate()\n\treturn &cfg, err\n}\n\n// ValidateOverrides validates the alert's provider override and, if present, the group override\nfunc (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {\n\t_, err := provider.GetConfig(group, alert)\n\treturn err\n}\n"
  },
  {
    "path": "alerting/provider/zulip/zulip_test.go",
    "content": "package zulip\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestAlertProvider_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tName          string\n\t\tAlertProvider AlertProvider\n\t\tExpectedError error\n\t}{\n\t\t{\n\t\t\tName:          \"Empty provider\",\n\t\t\tAlertProvider: AlertProvider{},\n\t\t\tExpectedError: ErrBotEmailNotSet,\n\t\t},\n\t\t{\n\t\t\tName: \"Empty channel id\",\n\t\t\tAlertProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tBotEmail:  \"something\",\n\t\t\t\t\tBotAPIKey: \"something\",\n\t\t\t\t\tDomain:    \"something\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedError: ErrChannelIDNotSet,\n\t\t},\n\t\t{\n\t\t\tName: \"Empty domain\",\n\t\t\tAlertProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tBotEmail:  \"something\",\n\t\t\t\t\tBotAPIKey: \"something\",\n\t\t\t\t\tChannelID: \"something\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedError: ErrDomainNotSet,\n\t\t},\n\t\t{\n\t\t\tName: \"Empty bot api key\",\n\t\t\tAlertProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tBotEmail:  \"something\",\n\t\t\t\t\tDomain:    \"something\",\n\t\t\t\t\tChannelID: \"something\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedError: ErrBotAPIKeyNotSet,\n\t\t},\n\t\t{\n\t\t\tName: \"Empty bot email\",\n\t\t\tAlertProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tBotAPIKey: \"something\",\n\t\t\t\t\tDomain:    \"something\",\n\t\t\t\t\tChannelID: \"something\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedError: ErrBotEmailNotSet,\n\t\t},\n\t\t{\n\t\t\tName: \"Valid provider\",\n\t\t\tAlertProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tBotEmail:  \"something\",\n\t\t\t\t\tBotAPIKey: \"something\",\n\t\t\t\t\tDomain:    \"something\",\n\t\t\t\t\tChannelID: \"something\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedError: nil,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tif err := scenario.AlertProvider.Validate(); !errors.Is(err, scenario.ExpectedError) {\n\t\t\t\tt.Errorf(\"ExpectedError error %v, got %v\", scenario.ExpectedError, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_buildRequestBody(t *testing.T) {\n\tbasicConfig := Config{\n\t\tBotEmail:  \"bot-email\",\n\t\tBotAPIKey: \"bot-api-key\",\n\t\tDomain:    \"domain\",\n\t\tChannelID: \"channel-id\",\n\t}\n\talertDesc := \"Description\"\n\tbasicAlert := alert.Alert{\n\t\tSuccessThreshold: 2,\n\t\tFailureThreshold: 3,\n\t\tDescription:      &alertDesc,\n\t}\n\ttestCases := []struct {\n\t\tname          string\n\t\tprovider      AlertProvider\n\t\talert         alert.Alert\n\t\tresolved      bool\n\t\thasConditions bool\n\t\texpectedBody  url.Values\n\t}{\n\t\t{\n\t\t\tname: \"Resolved alert with no conditions\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: basicConfig,\n\t\t\t},\n\t\t\talert:         basicAlert,\n\t\t\tresolved:      true,\n\t\t\thasConditions: false,\n\t\t\texpectedBody: url.Values{\n\t\t\t\t\"content\": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row\n> Description\n`},\n\t\t\t\t\"to\":    {\"channel-id\"},\n\t\t\t\t\"topic\": {\"Gatus\"},\n\t\t\t\t\"type\":  {\"channel\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Resolved alert with conditions\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: basicConfig,\n\t\t\t},\n\t\t\talert:         basicAlert,\n\t\t\tresolved:      true,\n\t\t\thasConditions: true,\n\t\t\texpectedBody: url.Values{\n\t\t\t\t\"content\": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row\n> Description\n\n:check: - ` + \"`[CONNECTED] == true`\" + `\n:check: - ` + \"`[STATUS] == 200`\" + `\n:check: - ` + \"`[BODY] != \\\"\\\"`\"},\n\t\t\t\t\"to\":    {\"channel-id\"},\n\t\t\t\t\"topic\": {\"Gatus\"},\n\t\t\t\t\"type\":  {\"channel\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Failed alert with no conditions\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: basicConfig,\n\t\t\t},\n\t\t\talert:         basicAlert,\n\t\t\tresolved:      false,\n\t\t\thasConditions: false,\n\t\t\texpectedBody: url.Values{\n\t\t\t\t\"content\": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row\n> Description\n`},\n\t\t\t\t\"to\":    {\"channel-id\"},\n\t\t\t\t\"topic\": {\"Gatus\"},\n\t\t\t\t\"type\":  {\"channel\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Failed alert with conditions\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: basicConfig,\n\t\t\t},\n\t\t\talert:         basicAlert,\n\t\t\tresolved:      false,\n\t\t\thasConditions: true,\n\t\t\texpectedBody: url.Values{\n\t\t\t\t\"content\": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row\n> Description\n\n:cross_mark: - ` + \"`[CONNECTED] == true`\" + `\n:cross_mark: - ` + \"`[STATUS] == 200`\" + `\n:cross_mark: - ` + \"`[BODY] != \\\"\\\"`\"},\n\t\t\t\t\"to\":    {\"channel-id\"},\n\t\t\t\t\"topic\": {\"Gatus\"},\n\t\t\t\t\"type\":  {\"channel\"},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tvar conditionResults []*endpoint.ConditionResult\n\t\t\tif tc.hasConditions {\n\t\t\t\tconditionResults = []*endpoint.ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: tc.resolved},\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: tc.resolved},\n\t\t\t\t\t{Condition: \"[BODY] != \\\"\\\"\", Success: tc.resolved},\n\t\t\t\t}\n\t\t\t}\n\t\t\tbody := tc.provider.buildRequestBody(\n\t\t\t\t&tc.provider.DefaultConfig,\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-Name\"},\n\t\t\t\t&tc.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: conditionResults,\n\t\t\t\t},\n\t\t\t\ttc.resolved,\n\t\t\t)\n\t\t\tvaluesResult, err := url.ParseQuery(body)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(err)\n\t\t\t}\n\t\t\tif fmt.Sprintf(\"%v\", valuesResult) != fmt.Sprintf(\"%v\", tc.expectedBody) {\n\t\t\t\tt.Errorf(\"Expected body:\\n%v\\ngot:\\n%v\", tc.expectedBody, valuesResult)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetDefaultAlert(t *testing.T) {\n\tif (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {\n\t\tt.Error(\"ExpectedError default alert to be not nil\")\n\t}\n\tif (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {\n\t\tt.Error(\"ExpectedError default alert to be nil\")\n\t}\n}\n\nfunc TestAlertProvider_Send(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tvalidateRequest := func(req *http.Request) {\n\t\tif req.URL.String() != \"https://custom-domain/api/v1/messages\" {\n\t\t\tt.Errorf(\"ExpectedError url https://custom-domain.zulipchat.com/api/v1/messages, got %s\", req.URL.String())\n\t\t}\n\t\tif req.Method != http.MethodPost {\n\t\t\tt.Errorf(\"ExpectedError POST request, got %s\", req.Method)\n\t\t}\n\t\tif req.Header.Get(\"Content-Type\") != \"application/x-www-form-urlencoded\" {\n\t\t\tt.Errorf(\"ExpectedError Content-Type header to be application/x-www-form-urlencoded, got %s\", req.Header.Get(\"Content-Type\"))\n\t\t}\n\t\tif req.Header.Get(\"User-Agent\") != \"Gatus\" {\n\t\t\tt.Errorf(\"ExpectedError User-Agent header to be Gatus, got %s\", req.Header.Get(\"User-Agent\"))\n\t\t}\n\t}\n\tbasicConfig := Config{\n\t\tBotEmail:  \"bot-email\",\n\t\tBotAPIKey: \"bot-api-key\",\n\t\tDomain:    \"custom-domain\",\n\t\tChannelID: \"channel-id\",\n\t}\n\tbasicAlert := alert.Alert{\n\t\tSuccessThreshold: 2,\n\t\tFailureThreshold: 3,\n\t}\n\ttestCases := []struct {\n\t\tname             string\n\t\tprovider         AlertProvider\n\t\talert            alert.Alert\n\t\tresolved         bool\n\t\tmockRoundTripper test.MockRoundTripper\n\t\texpectedError    bool\n\t}{\n\t\t{\n\t\t\tname: \"resolved\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: basicConfig,\n\t\t\t},\n\t\t\talert:    basicAlert,\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {\n\t\t\t\tvalidateRequest(req)\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"resolved error\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: basicConfig,\n\t\t\t},\n\t\t\talert:    basicAlert,\n\t\t\tresolved: true,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {\n\t\t\t\tvalidateRequest(req)\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t\t{\n\t\t\tname: \"triggered\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: basicConfig,\n\t\t\t},\n\t\t\talert:    basicAlert,\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {\n\t\t\t\tvalidateRequest(req)\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK}\n\t\t\t}),\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"triggered error\",\n\t\t\tprovider: AlertProvider{\n\t\t\t\tDefaultConfig: basicConfig,\n\t\t\t},\n\t\t\talert:    basicAlert,\n\t\t\tresolved: false,\n\t\t\tmockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {\n\t\t\t\tvalidateRequest(req)\n\t\t\t\treturn &http.Response{StatusCode: http.StatusInternalServerError}\n\t\t\t}),\n\t\t\texpectedError: true,\n\t\t},\n\t}\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tclient.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})\n\t\t\terr := tc.provider.Send(\n\t\t\t\t&endpoint.Endpoint{Name: \"endpoint-Name\"},\n\t\t\t\t&tc.alert,\n\t\t\t\t&endpoint.Result{\n\t\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t\t{Condition: \"[CONNECTED] == true\", Success: tc.resolved},\n\t\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: tc.resolved},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\ttc.resolved,\n\t\t\t)\n\t\t\tif tc.expectedError && err == nil {\n\t\t\t\tt.Error(\"ExpectedError error, got none\")\n\t\t\t}\n\t\t\tif !tc.expectedError && err != nil {\n\t\t\t\tt.Errorf(\"ExpectedError no error, got: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAlertProvider_GetConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tName           string\n\t\tProvider       AlertProvider\n\t\tInputGroup     string\n\t\tInputAlert     alert.Alert\n\t\tExpectedOutput Config\n\t}{\n\t\t{\n\t\t\tName: \"provider-no-overrides\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tBotEmail:  \"default-bot-email\",\n\t\t\t\t\tBotAPIKey: \"default-bot-api-key\",\n\t\t\t\t\tDomain:    \"default-domain\",\n\t\t\t\t\tChannelID: \"default-channel-id\",\n\t\t\t\t},\n\t\t\t\tOverrides: nil,\n\t\t\t},\n\t\t\tInputGroup: \"group\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tBotEmail:  \"default-bot-email\",\n\t\t\t\tBotAPIKey: \"default-bot-api-key\",\n\t\t\t\tDomain:    \"default-domain\",\n\t\t\t\tChannelID: \"default-channel-id\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-no-group-should-default\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tBotEmail:  \"default-bot-email\",\n\t\t\t\t\tBotAPIKey: \"default-bot-api-key\",\n\t\t\t\t\tDomain:    \"default-domain\",\n\t\t\t\t\tChannelID: \"default-channel-id\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup:  \"group\",\n\t\t\t\t\t\tConfig: Config{ChannelID: \"group-channel-id\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tBotEmail:  \"default-bot-email\",\n\t\t\t\tBotAPIKey: \"default-bot-api-key\",\n\t\t\t\tDomain:    \"default-domain\",\n\t\t\t\tChannelID: \"default-channel-id\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-override-specify-group-should-override\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tBotEmail:  \"default-bot-email\",\n\t\t\t\t\tBotAPIKey: \"default-bot-api-key\",\n\t\t\t\t\tDomain:    \"default-domain\",\n\t\t\t\t\tChannelID: \"default-channel-id\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup: \"group\",\n\t\t\t\t\t\tConfig: Config{\n\t\t\t\t\t\t\tBotEmail:  \"group-bot-email\",\n\t\t\t\t\t\t\tBotAPIKey: \"group-bot-api-key\",\n\t\t\t\t\t\t\tDomain:    \"group-domain\",\n\t\t\t\t\t\t\tChannelID: \"group-channel-id\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"group\",\n\t\t\tInputAlert: alert.Alert{},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tBotEmail:  \"group-bot-email\",\n\t\t\t\tBotAPIKey: \"group-bot-api-key\",\n\t\t\t\tDomain:    \"group-domain\",\n\t\t\t\tChannelID: \"group-channel-id\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"provider-with-group-override-and-alert-override--alert-override-should-take-precedence\",\n\t\t\tProvider: AlertProvider{\n\t\t\t\tDefaultConfig: Config{\n\t\t\t\t\tBotEmail:  \"default-bot-email\",\n\t\t\t\t\tBotAPIKey: \"default-bot-api-key\",\n\t\t\t\t\tDomain:    \"default-domain\",\n\t\t\t\t\tChannelID: \"default-channel-id\",\n\t\t\t\t},\n\t\t\t\tOverrides: []Override{\n\t\t\t\t\t{\n\t\t\t\t\t\tGroup: \"group\",\n\t\t\t\t\t\tConfig: Config{\n\t\t\t\t\t\t\tBotEmail:  \"group-bot-email\",\n\t\t\t\t\t\t\tBotAPIKey: \"group-bot-api-key\",\n\t\t\t\t\t\t\tDomain:    \"group-domain\",\n\t\t\t\t\t\t\tChannelID: \"group-channel-id\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tInputGroup: \"group\",\n\t\t\tInputAlert: alert.Alert{ProviderOverride: map[string]any{\n\t\t\t\t\"bot-email\":   \"alert-bot-email\",\n\t\t\t\t\"bot-api-key\": \"alert-bot-api-key\",\n\t\t\t\t\"domain\":      \"alert-domain\",\n\t\t\t\t\"channel-id\":  \"alert-channel-id\",\n\t\t\t}},\n\t\t\tExpectedOutput: Config{\n\t\t\t\tBotEmail:  \"alert-bot-email\",\n\t\t\t\tBotAPIKey: \"alert-bot-api-key\",\n\t\t\t\tDomain:    \"alert-domain\",\n\t\t\t\tChannelID: \"alert-channel-id\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tgot, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\tif got.BotEmail != scenario.ExpectedOutput.BotEmail {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.ExpectedOutput.BotEmail, got.BotEmail)\n\t\t\t}\n\t\t\tif got.BotAPIKey != scenario.ExpectedOutput.BotAPIKey {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.ExpectedOutput.BotAPIKey, got.BotAPIKey)\n\t\t\t}\n\t\t\tif got.Domain != scenario.ExpectedOutput.Domain {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.ExpectedOutput.Domain, got.Domain)\n\t\t\t}\n\t\t\tif got.ChannelID != scenario.ExpectedOutput.ChannelID {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.ExpectedOutput.ChannelID, got.ChannelID)\n\t\t\t}\n\t\t\t// Test ValidateOverrides as well, since it really just calls GetConfig\n\t\t\tif err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "api/api.go",
    "content": "package api\n\nimport (\n\t\"io/fs\"\n\t\"net/http\"\n\t\"os\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/ui\"\n\t\"github.com/TwiN/gatus/v5/config/web\"\n\tstatic \"github.com/TwiN/gatus/v5/web\"\n\t\"github.com/TwiN/health\"\n\t\"github.com/TwiN/logr\"\n\tfiber \"github.com/gofiber/fiber/v2\"\n\t\"github.com/gofiber/fiber/v2/middleware/adaptor\"\n\t\"github.com/gofiber/fiber/v2/middleware/compress\"\n\t\"github.com/gofiber/fiber/v2/middleware/cors\"\n\tfiberfs \"github.com/gofiber/fiber/v2/middleware/filesystem\"\n\t\"github.com/gofiber/fiber/v2/middleware/recover\"\n\t\"github.com/gofiber/fiber/v2/middleware/redirect\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n)\n\ntype API struct {\n\trouter *fiber.App\n}\n\nfunc New(cfg *config.Config) *API {\n\tapi := &API{}\n\tif cfg.Web == nil {\n\t\tlogr.Warnf(\"[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration\")\n\t\tcfg.Web = web.GetDefaultConfig()\n\t}\n\tif cfg.UI == nil {\n\t\tlogr.Warnf(\"[api.New] nil ui config passed as parameter. This should only happen in tests. Using default ui configuration\")\n\t\tcfg.UI = ui.GetDefaultConfig()\n\t}\n\tapi.router = api.createRouter(cfg)\n\treturn api\n}\n\nfunc (a *API) Router() *fiber.App {\n\treturn a.router\n}\n\nfunc (a *API) createRouter(cfg *config.Config) *fiber.App {\n\tapp := fiber.New(fiber.Config{\n\t\tErrorHandler: func(c *fiber.Ctx, err error) error {\n\t\t\tlogr.Errorf(\"[api.ErrorHandler] %s\", err.Error())\n\t\t\treturn fiber.DefaultErrorHandler(c, err)\n\t\t},\n\t\tReadBufferSize: cfg.Web.ReadBufferSize,\n\t\tNetwork:        fiber.NetworkTCP,\n\t\tImmutable:      true, // If not enabled, will cause issues due to fiber's zero allocation. See #1268 and https://docs.gofiber.io/#zero-allocation\n\t})\n\tif os.Getenv(\"ENVIRONMENT\") == \"dev\" {\n\t\tapp.Use(cors.New(cors.Config{\n\t\t\tAllowOrigins:     \"http://localhost:8081\",\n\t\t\tAllowCredentials: true,\n\t\t}))\n\t}\n\t// Middlewares\n\tapp.Use(recover.New())\n\tapp.Use(compress.New())\n\t// Define metrics handler, if necessary\n\tif cfg.Metrics {\n\t\tmetricsHandler := promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{\n\t\t\tDisableCompression: true,\n\t\t}))\n\t\tapp.Get(\"/metrics\", adaptor.HTTPHandler(metricsHandler))\n\t}\n\t// Define main router\n\tapiRouter := app.Group(\"/api\")\n\t////////////////////////\n\t// UNPROTECTED ROUTES //\n\t////////////////////////\n\tunprotectedAPIRouter := apiRouter.Group(\"/\")\n\tunprotectedAPIRouter.Get(\"/v1/config\", ConfigHandler{securityConfig: cfg.Security, config: cfg}.GetConfig)\n\tunprotectedAPIRouter.Get(\"/v1/endpoints/:key/health/badge.svg\", HealthBadge)\n\tunprotectedAPIRouter.Get(\"/v1/endpoints/:key/health/badge.shields\", HealthBadgeShields)\n\tunprotectedAPIRouter.Get(\"/v1/endpoints/:key/uptimes/:duration\", UptimeRaw)\n\tunprotectedAPIRouter.Get(\"/v1/endpoints/:key/uptimes/:duration/badge.svg\", UptimeBadge)\n\tunprotectedAPIRouter.Get(\"/v1/endpoints/:key/response-times/:duration\", ResponseTimeRaw)\n\tunprotectedAPIRouter.Get(\"/v1/endpoints/:key/response-times/:duration/badge.svg\", ResponseTimeBadge(cfg))\n\tunprotectedAPIRouter.Get(\"/v1/endpoints/:key/response-times/:duration/chart.svg\", ResponseTimeChart)\n\tunprotectedAPIRouter.Get(\"/v1/endpoints/:key/response-times/:duration/history\", ResponseTimeHistory)\n\t// This endpoint requires authz with bearer token, so technically it is protected\n\tunprotectedAPIRouter.Post(\"/v1/endpoints/:key/external\", CreateExternalEndpointResult(cfg))\n\t// SPA\n\tapp.Get(\"/\", SinglePageApplication(cfg.UI))\n\tapp.Get(\"/endpoints/:key\", SinglePageApplication(cfg.UI))\n\tapp.Get(\"/suites/:key\", SinglePageApplication(cfg.UI))\n\t// Health endpoint\n\thealthHandler := health.Handler().WithJSON(true)\n\tapp.Get(\"/health\", func(c *fiber.Ctx) error {\n\t\tstatusCode, body := healthHandler.GetResponseStatusCodeAndBody()\n\t\treturn c.Status(statusCode).Send(body)\n\t})\n\t// Custom CSS\n\tapp.Get(\"/css/custom.css\", CustomCSSHandler{customCSS: cfg.UI.CustomCSS}.GetCustomCSS)\n\t// Everything else falls back on static content\n\tapp.Use(redirect.New(redirect.Config{\n\t\tRules: map[string]string{\n\t\t\t\"/index.html\": \"/\",\n\t\t},\n\t\tStatusCode: 301,\n\t}))\n\tstaticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tapp.Use(\"/\", fiberfs.New(fiberfs.Config{\n\t\tRoot:   http.FS(staticFileSystem),\n\t\tIndex:  \"index.html\",\n\t\tBrowse: true,\n\t}))\n\t//////////////////////\n\t// PROTECTED ROUTES //\n\t//////////////////////\n\t// ORDER IS IMPORTANT: all routes applied AFTER the security middleware will require authn\n\tprotectedAPIRouter := apiRouter.Group(\"/\")\n\tif cfg.Security != nil {\n\t\tif err := cfg.Security.RegisterHandlers(app); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tif err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n\tprotectedAPIRouter.Get(\"/v1/endpoints/statuses\", EndpointStatuses(cfg))\n\tprotectedAPIRouter.Get(\"/v1/endpoints/:key/statuses\", EndpointStatus(cfg))\n\tprotectedAPIRouter.Get(\"/v1/suites/statuses\", SuiteStatuses(cfg))\n\tprotectedAPIRouter.Get(\"/v1/suites/:key/statuses\", SuiteStatus(cfg))\n\treturn app\n}\n"
  },
  {
    "path": "api/api_test.go",
    "content": "package api\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/ui\"\n\t\"github.com/TwiN/gatus/v5/security\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\nfunc TestNew(t *testing.T) {\n\ttype Scenario struct {\n\t\tName         string\n\t\tPath         string\n\t\tExpectedCode int\n\t\tGzip         bool\n\t\tWithSecurity bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:         \"health\",\n\t\t\tPath:         \"/health\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"custom.css\",\n\t\t\tPath:         \"/css/custom.css\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"custom.css-gzipped\",\n\t\t\tPath:         \"/css/custom.css\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t\tGzip:         true,\n\t\t},\n\t\t{\n\t\t\tName:         \"metrics\",\n\t\t\tPath:         \"/metrics\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"favicon.ico\",\n\t\t\tPath:         \"/favicon.ico\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"app.js\",\n\t\t\tPath:         \"/js/app.js\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"app.js-gzipped\",\n\t\t\tPath:         \"/js/app.js\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t\tGzip:         true,\n\t\t},\n\t\t{\n\t\t\tName:         \"chunk-vendors.js\",\n\t\t\tPath:         \"/js/chunk-vendors.js\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"chunk-vendors.js-gzipped\",\n\t\t\tPath:         \"/js/chunk-vendors.js\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t\tGzip:         true,\n\t\t},\n\t\t{\n\t\t\tName:         \"index\",\n\t\t\tPath:         \"/\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"index-html-redirect\",\n\t\t\tPath:         \"/index.html\",\n\t\t\tExpectedCode: fiber.StatusMovedPermanently,\n\t\t},\n\t\t{\n\t\t\tName:         \"index-should-return-200-even-if-not-authenticated\",\n\t\t\tPath:         \"/\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t\tWithSecurity: true,\n\t\t},\n\t\t{\n\t\t\tName:         \"endpoints-should-return-401-if-not-authenticated\",\n\t\t\tPath:         \"/api/v1/endpoints/statuses\",\n\t\t\tExpectedCode: fiber.StatusUnauthorized,\n\t\t\tWithSecurity: true,\n\t\t},\n\t\t{\n\t\t\tName:         \"config-should-return-200-even-if-not-authenticated\",\n\t\t\tPath:         \"/api/v1/config\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t\tWithSecurity: true,\n\t\t},\n\t\t{\n\t\t\tName:         \"config-should-always-return-200\",\n\t\t\tPath:         \"/api/v1/config\",\n\t\t\tExpectedCode: fiber.StatusOK,\n\t\t\tWithSecurity: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg := &config.Config{Metrics: true, UI: &ui.Config{}}\n\t\t\tif scenario.WithSecurity {\n\t\t\t\tcfg.Security = &security.Config{\n\t\t\t\t\tBasic: &security.BasicConfig{\n\t\t\t\t\t\tUsername:                        \"john.doe\",\n\t\t\t\t\t\tPasswordBcryptHashBase64Encoded: \"JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT\",\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t}\n\t\t\tapi := New(cfg)\n\t\t\trouter := api.Router()\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tif scenario.Gzip {\n\t\t\t\trequest.Header.Set(\"Accept-Encoding\", \"gzip\")\n\t\t\t}\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "api/badge.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/ui\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\nconst (\n\tbadgeColorHexAwesome  = \"#40cc11\"\n\tbadgeColorHexGreat    = \"#94cc11\"\n\tbadgeColorHexGood     = \"#ccd311\"\n\tbadgeColorHexPassable = \"#ccb311\"\n\tbadgeColorHexBad      = \"#cc8111\"\n\tbadgeColorHexVeryBad  = \"#c7130a\"\n)\n\nconst (\n\tHealthStatusUp      = \"up\"\n\tHealthStatusDown    = \"down\"\n\tHealthStatusUnknown = \"?\"\n)\n\nvar (\n\tbadgeColors = []string{badgeColorHexAwesome, badgeColorHexGreat, badgeColorHexGood, badgeColorHexPassable, badgeColorHexBad}\n)\n\n// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.\n//\n// Valid values for :duration -> 30d, 7d, 24h, 1h\nfunc UptimeBadge(c *fiber.Ctx) error {\n\tduration := c.Params(\"duration\")\n\tvar from time.Time\n\tswitch duration {\n\tcase \"30d\":\n\t\tfrom = time.Now().Add(-30 * 24 * time.Hour)\n\tcase \"7d\":\n\t\tfrom = time.Now().Add(-7 * 24 * time.Hour)\n\tcase \"24h\":\n\t\tfrom = time.Now().Add(-24 * time.Hour)\n\tcase \"1h\":\n\t\tfrom = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little\n\tdefault:\n\t\treturn c.Status(400).SendString(\"Durations supported: 30d, 7d, 24h, 1h\")\n\t}\n\tkey, err := url.QueryUnescape(c.Params(\"key\"))\n\tif err != nil {\n\t\treturn c.Status(400).SendString(\"invalid key encoding\")\n\t}\n\tuptime, err := store.Get().GetUptimeByKey(key, from, time.Now())\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t} else if errors.Is(err, common.ErrInvalidTimeRange) {\n\t\t\treturn c.Status(400).SendString(err.Error())\n\t\t}\n\t\treturn c.Status(500).SendString(err.Error())\n\t}\n\tc.Set(\"Content-Type\", \"image/svg+xml\")\n\tc.Set(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n\tc.Set(\"Expires\", \"0\")\n\treturn c.Status(200).Send(generateUptimeBadgeSVG(duration, uptime))\n}\n\n// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.\n//\n// Valid values for :duration -> 30d, 7d, 24h, 1h\nfunc ResponseTimeBadge(cfg *config.Config) fiber.Handler {\n\treturn func(c *fiber.Ctx) error {\n\t\tduration := c.Params(\"duration\")\n\t\tvar from time.Time\n\t\tswitch duration {\n\t\tcase \"30d\":\n\t\t\tfrom = time.Now().Add(-30 * 24 * time.Hour)\n\t\tcase \"7d\":\n\t\t\tfrom = time.Now().Add(-7 * 24 * time.Hour)\n\t\tcase \"24h\":\n\t\t\tfrom = time.Now().Add(-24 * time.Hour)\n\t\tcase \"1h\":\n\t\t\tfrom = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little\n\t\tdefault:\n\t\t\treturn c.Status(400).SendString(\"Durations supported: 30d, 7d, 24h, 1h\")\n\t\t}\n\t\tkey, err := url.QueryUnescape(c.Params(\"key\"))\n\t\tif err != nil {\n\t\t\treturn c.Status(400).SendString(\"invalid key encoding\")\n\t\t}\n\t\taverageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())\n\t\tif err != nil {\n\t\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t\t} else if errors.Is(err, common.ErrInvalidTimeRange) {\n\t\t\t\treturn c.Status(400).SendString(err.Error())\n\t\t\t}\n\t\t\treturn c.Status(500).SendString(err.Error())\n\t\t}\n\t\tc.Set(\"Content-Type\", \"image/svg+xml\")\n\t\tc.Set(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n\t\tc.Set(\"Expires\", \"0\")\n\t\treturn c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, cfg))\n\t}\n}\n\n// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.\nfunc HealthBadge(c *fiber.Ctx) error {\n\tkey, err := url.QueryUnescape(c.Params(\"key\"))\n\tif err != nil {\n\t\treturn c.Status(400).SendString(\"invalid key encoding\")\n\t}\n\tpagingConfig := paging.NewEndpointStatusParams()\n\tstatus, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t} else if errors.Is(err, common.ErrInvalidTimeRange) {\n\t\t\treturn c.Status(400).SendString(err.Error())\n\t\t}\n\t\treturn c.Status(500).SendString(err.Error())\n\t}\n\thealthStatus := HealthStatusUnknown\n\tif len(status.Results) > 0 {\n\t\tif status.Results[0].Success {\n\t\t\thealthStatus = HealthStatusUp\n\t\t} else {\n\t\t\thealthStatus = HealthStatusDown\n\t\t}\n\t}\n\tc.Set(\"Content-Type\", \"image/svg+xml\")\n\tc.Set(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n\tc.Set(\"Expires\", \"0\")\n\treturn c.Status(200).Send(generateHealthBadgeSVG(healthStatus))\n}\n\nfunc HealthBadgeShields(c *fiber.Ctx) error {\n\tkey, err := url.QueryUnescape(c.Params(\"key\"))\n\tif err != nil {\n\t\treturn c.Status(400).SendString(\"invalid key encoding\")\n\t}\n\tpagingConfig := paging.NewEndpointStatusParams()\n\tstatus, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t} else if errors.Is(err, common.ErrInvalidTimeRange) {\n\t\t\treturn c.Status(400).SendString(err.Error())\n\t\t}\n\t\treturn c.Status(500).SendString(err.Error())\n\t}\n\thealthStatus := HealthStatusUnknown\n\tif len(status.Results) > 0 {\n\t\tif status.Results[0].Success {\n\t\t\thealthStatus = HealthStatusUp\n\t\t} else {\n\t\t\thealthStatus = HealthStatusDown\n\t\t}\n\t}\n\tc.Set(\"Content-Type\", \"application/json\")\n\tc.Set(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n\tc.Set(\"Expires\", \"0\")\n\tjsonData, err := generateHealthBadgeShields(healthStatus)\n\tif err != nil {\n\t\treturn c.Status(500).SendString(err.Error())\n\t}\n\treturn c.Status(200).Send(jsonData)\n}\n\nfunc generateUptimeBadgeSVG(duration string, uptime float64) []byte {\n\tvar labelWidth, valueWidth, valueWidthAdjustment int\n\tswitch duration {\n\tcase \"30d\":\n\t\tlabelWidth = 70\n\tcase \"7d\":\n\t\tlabelWidth = 65\n\tcase \"24h\":\n\t\tlabelWidth = 70\n\tcase \"1h\":\n\t\tlabelWidth = 65\n\tdefault:\n\t}\n\tcolor := getBadgeColorFromUptime(uptime)\n\tsanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf(\"%.2f\", uptime*100), \"0\"), \".\") + \"%\"\n\tif strings.Contains(sanitizedValue, \".\") {\n\t\tvalueWidthAdjustment = -10\n\t}\n\tvalueWidth = (len(sanitizedValue) * 11) + valueWidthAdjustment\n\twidth := labelWidth + valueWidth\n\tlabelX := labelWidth / 2\n\tvalueX := labelWidth + (valueWidth / 2)\n\tsvg := []byte(fmt.Sprintf(`<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"20\">\n  <linearGradient id=\"b\" x2=\"0\" y2=\"100%%\">\n    <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n    <stop offset=\"1\" stop-opacity=\".1\"/>\n  </linearGradient>\n  <mask id=\"a\">\n    <rect width=\"%d\" height=\"20\" rx=\"3\" fill=\"#fff\"/>\n  </mask>\n  <g mask=\"url(#a)\">\n    <path fill=\"#555\" d=\"M0 0h%dv20H0z\"/>\n    <path fill=\"%s\" d=\"M%d 0h%dv20H%dz\"/>\n    <path fill=\"url(#b)\" d=\"M0 0h%dv20H0z\"/>\n  </g>\n  <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">\n    <text x=\"%d\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">\n      uptime %s\n    </text>\n    <text x=\"%d\" y=\"14\">\n      uptime %s\n    </text>\n    <text x=\"%d\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">\n      %s\n    </text>\n    <text x=\"%d\" y=\"14\">\n      %s\n    </text>\n  </g>\n</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))\n\treturn svg\n}\n\nfunc getBadgeColorFromUptime(uptime float64) string {\n\tif uptime >= 0.975 {\n\t\treturn badgeColorHexAwesome\n\t} else if uptime >= 0.95 {\n\t\treturn badgeColorHexGreat\n\t} else if uptime >= 0.9 {\n\t\treturn badgeColorHexGood\n\t} else if uptime >= 0.8 {\n\t\treturn badgeColorHexPassable\n\t} else if uptime >= 0.65 {\n\t\treturn badgeColorHexBad\n\t}\n\treturn badgeColorHexVeryBad\n}\n\nfunc generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {\n\tvar labelWidth, valueWidth int\n\tswitch duration {\n\tcase \"30d\":\n\t\tlabelWidth = 110\n\tcase \"7d\":\n\t\tlabelWidth = 105\n\tcase \"24h\":\n\t\tlabelWidth = 110\n\tcase \"1h\":\n\t\tlabelWidth = 105\n\tdefault:\n\t}\n\tcolor := getBadgeColorFromResponseTime(averageResponseTime, key, cfg)\n\tsanitizedValue := strconv.Itoa(averageResponseTime) + \"ms\"\n\tvalueWidth = len(sanitizedValue) * 11\n\twidth := labelWidth + valueWidth\n\tlabelX := labelWidth / 2\n\tvalueX := labelWidth + (valueWidth / 2)\n\tsvg := []byte(fmt.Sprintf(`<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"20\">\n  <linearGradient id=\"b\" x2=\"0\" y2=\"100%%\">\n    <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n    <stop offset=\"1\" stop-opacity=\".1\"/>\n  </linearGradient>\n  <mask id=\"a\">\n    <rect width=\"%d\" height=\"20\" rx=\"3\" fill=\"#fff\"/>\n  </mask>\n  <g mask=\"url(#a)\">\n    <path fill=\"#555\" d=\"M0 0h%dv20H0z\"/>\n    <path fill=\"%s\" d=\"M%d 0h%dv20H%dz\"/>\n    <path fill=\"url(#b)\" d=\"M0 0h%dv20H0z\"/>\n  </g>\n  <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">\n    <text x=\"%d\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">\n      response time %s\n    </text>\n    <text x=\"%d\" y=\"14\">\n      response time %s\n    </text>\n    <text x=\"%d\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">\n      %s\n    </text>\n    <text x=\"%d\" y=\"14\">\n      %s\n    </text>\n  </g>\n</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))\n\treturn svg\n}\n\nfunc getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {\n\tthresholds := ui.GetDefaultConfig().Badge.ResponseTime.Thresholds\n\tif endpoint := cfg.GetEndpointByKey(key); endpoint != nil {\n\t\tthresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds\n\t}\n\t// the threshold config requires 5 values, so we can be sure it's set here\n\tfor i := range 5 {\n\t\tif responseTime <= thresholds[i] {\n\t\t\treturn badgeColors[i]\n\t\t}\n\t}\n\treturn badgeColorHexVeryBad\n}\n\nfunc generateHealthBadgeSVG(healthStatus string) []byte {\n\tvar labelWidth, valueWidth int\n\tswitch healthStatus {\n\tcase HealthStatusUp:\n\t\tvalueWidth = 28\n\tcase HealthStatusDown:\n\t\tvalueWidth = 44\n\tcase HealthStatusUnknown:\n\t\tvalueWidth = 10\n\tdefault:\n\t}\n\tcolor := getBadgeColorFromHealth(healthStatus)\n\tlabelWidth = 48\n\n\twidth := labelWidth + valueWidth\n\tlabelX := labelWidth / 2\n\tvalueX := labelWidth + (valueWidth / 2)\n\tsvg := []byte(fmt.Sprintf(`<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"20\">\n  <linearGradient id=\"b\" x2=\"0\" y2=\"100%%\">\n    <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n    <stop offset=\"1\" stop-opacity=\".1\"/>\n  </linearGradient>\n  <mask id=\"a\">\n    <rect width=\"%d\" height=\"20\" rx=\"3\" fill=\"#fff\"/>\n  </mask>\n  <g mask=\"url(#a)\">\n    <path fill=\"#555\" d=\"M0 0h%dv20H0z\"/>\n    <path fill=\"%s\" d=\"M%d 0h%dv20H%dz\"/>\n    <path fill=\"url(#b)\" d=\"M0 0h%dv20H0z\"/>\n  </g>\n  <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">\n    <text x=\"%d\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">\n      health\n    </text>\n    <text x=\"%d\" y=\"14\">\n      health\n    </text>\n    <text x=\"%d\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">\n      %s\n    </text>\n    <text x=\"%d\" y=\"14\">\n      %s\n    </text>\n  </g>\n</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, labelX, valueX, healthStatus, valueX, healthStatus))\n\n\treturn svg\n}\n\nfunc generateHealthBadgeShields(healthStatus string) ([]byte, error) {\n\tcolor := getBadgeShieldsColorFromHealth(healthStatus)\n\tdata := map[string]interface{}{\n\t\t\"schemaVersion\": 1,\n\t\t\"label\":         \"gatus\",\n\t\t\"message\":       healthStatus,\n\t\t\"color\":         color,\n\t}\n\treturn json.Marshal(data)\n}\n\nfunc getBadgeColorFromHealth(healthStatus string) string {\n\tif healthStatus == HealthStatusUp {\n\t\treturn badgeColorHexAwesome\n\t} else if healthStatus == HealthStatusDown {\n\t\treturn badgeColorHexVeryBad\n\t}\n\treturn badgeColorHexPassable\n}\n\nfunc getBadgeShieldsColorFromHealth(healthStatus string) string {\n\tif healthStatus == HealthStatusUp {\n\t\treturn \"brightgreen\"\n\t} else if healthStatus == HealthStatusDown {\n\t\treturn \"red\"\n\t}\n\treturn \"yellow\"\n}\n"
  },
  {
    "path": "api/badge_test.go",
    "content": "package api\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/ui\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/watchdog\"\n)\n\nfunc TestBadge(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tcfg := &config.Config{\n\t\tMetrics: true,\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName:  \"frontend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:  \"backend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t},\n\t}\n\n\tcfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()\n\tcfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()\n\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})\n\tapi := New(cfg)\n\trouter := api.Router()\n\ttype Scenario struct {\n\t\tName         string\n\t\tPath         string\n\t\tExpectedCode int\n\t\tGzip         bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:         \"badge-uptime-1h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/uptimes/1h/badge.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-uptime-24h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/uptimes/24h/badge.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-uptime-7d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/uptimes/7d/badge.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-uptime-with-invalid-duration\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/uptimes/3d/badge.svg\",\n\t\t\tExpectedCode: http.StatusBadRequest,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-uptime-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/endpoints/invalid_key/uptimes/7d/badge.svg\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-response-time-1h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/1h/badge.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-response-time-24h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/24h/badge.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-response-time-7d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/7d/badge.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-response-time-with-invalid-duration\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/3d/badge.svg\",\n\t\t\tExpectedCode: http.StatusBadRequest,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-response-time-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/endpoints/invalid_key/response-times/7d/badge.svg\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-health-up\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/health/badge.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-health-down\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/health/badge.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-health-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/endpoints/invalid_key/health/badge.svg\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-shields-health-up\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/health/badge.shields\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-shields-health-down\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/health/badge.shields\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"badge-shields-health-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/endpoints/invalid_key/health/badge.shields\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t\t{\n\t\t\tName:         \"chart-response-time-24h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/24h/chart.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"chart-response-time-7d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/7d/chart.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"chart-response-time-with-invalid-duration\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/3d/chart.svg\",\n\t\t\tExpectedCode: http.StatusBadRequest,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tif scenario.Gzip {\n\t\t\t\trequest.Header.Set(\"Accept-Encoding\", \"gzip\")\n\t\t\t}\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetBadgeColorFromUptime(t *testing.T) {\n\tscenarios := []struct {\n\t\tUptime        float64\n\t\tExpectedColor string\n\t}{\n\t\t{\n\t\t\tUptime:        1,\n\t\t\tExpectedColor: badgeColorHexAwesome,\n\t\t},\n\t\t{\n\t\t\tUptime:        0.99,\n\t\t\tExpectedColor: badgeColorHexAwesome,\n\t\t},\n\t\t{\n\t\t\tUptime:        0.97,\n\t\t\tExpectedColor: badgeColorHexGreat,\n\t\t},\n\t\t{\n\t\t\tUptime:        0.95,\n\t\t\tExpectedColor: badgeColorHexGreat,\n\t\t},\n\t\t{\n\t\t\tUptime:        0.93,\n\t\t\tExpectedColor: badgeColorHexGood,\n\t\t},\n\t\t{\n\t\t\tUptime:        0.9,\n\t\t\tExpectedColor: badgeColorHexGood,\n\t\t},\n\t\t{\n\t\t\tUptime:        0.85,\n\t\t\tExpectedColor: badgeColorHexPassable,\n\t\t},\n\t\t{\n\t\t\tUptime:        0.7,\n\t\t\tExpectedColor: badgeColorHexBad,\n\t\t},\n\t\t{\n\t\t\tUptime:        0.65,\n\t\t\tExpectedColor: badgeColorHexBad,\n\t\t},\n\t\t{\n\t\t\tUptime:        0.6,\n\t\t\tExpectedColor: badgeColorHexVeryBad,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(\"uptime-\"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) {\n\t\t\tif getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor {\n\t\t\t\tt.Errorf(\"expected %s from %f, got %v\", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetBadgeColorFromResponseTime(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\n\tvar (\n\t\tfirstCondition  = endpoint.Condition(\"[STATUS] == 200\")\n\t\tsecondCondition = endpoint.Condition(\"[RESPONSE_TIME] < 500\")\n\t\tthirdCondition  = endpoint.Condition(\"[CERTIFICATE_EXPIRATION] < 72h\")\n\t)\n\n\tfirstTestEndpoint := endpoint.Endpoint{\n\t\tName:                    \"a\",\n\t\tURL:                     \"https://example.org/what/ever\",\n\t\tMethod:                  \"GET\",\n\t\tBody:                    \"body\",\n\t\tInterval:                30 * time.Second,\n\t\tConditions:              []endpoint.Condition{firstCondition, secondCondition, thirdCondition},\n\t\tAlerts:                  nil,\n\t\tNumberOfFailuresInARow:  0,\n\t\tNumberOfSuccessesInARow: 0,\n\t\tUIConfig:                ui.GetDefaultConfig(),\n\t}\n\tsecondTestEndpoint := endpoint.Endpoint{\n\t\tName:                    \"b\",\n\t\tURL:                     \"https://example.org/what/ever\",\n\t\tMethod:                  \"GET\",\n\t\tBody:                    \"body\",\n\t\tInterval:                30 * time.Second,\n\t\tConditions:              []endpoint.Condition{firstCondition, secondCondition, thirdCondition},\n\t\tAlerts:                  nil,\n\t\tNumberOfFailuresInARow:  0,\n\t\tNumberOfSuccessesInARow: 0,\n\t\tUIConfig: &ui.Config{\n\t\t\tBadge: &ui.Badge{\n\t\t\t\tResponseTime: &ui.ResponseTime{\n\t\t\t\t\tThresholds: []int{100, 500, 1000, 2000, 3000},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tcfg := &config.Config{\n\t\tMetrics:   true,\n\t\tEndpoints: []*endpoint.Endpoint{&firstTestEndpoint, &secondTestEndpoint},\n\t}\n\n\ttestSuccessfulResult := endpoint.Result{\n\t\tHostname:              \"example.org\",\n\t\tIP:                    \"127.0.0.1\",\n\t\tHTTPStatus:            200,\n\t\tErrors:                nil,\n\t\tConnected:             true,\n\t\tSuccess:               true,\n\t\tTimestamp:             time.Now(),\n\t\tDuration:              150 * time.Millisecond,\n\t\tCertificateExpiration: 10 * time.Hour,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{\n\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[CERTIFICATE_EXPIRATION] < 72h\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t},\n\t}\n\n\tstore.Get().InsertEndpointResult(&firstTestEndpoint, &testSuccessfulResult)\n\tstore.Get().InsertEndpointResult(&secondTestEndpoint, &testSuccessfulResult)\n\n\tscenarios := []struct {\n\t\tKey           string\n\t\tResponseTime  int\n\t\tExpectedColor string\n\t}{\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  10,\n\t\t\tExpectedColor: badgeColorHexAwesome,\n\t\t},\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  50,\n\t\t\tExpectedColor: badgeColorHexAwesome,\n\t\t},\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  75,\n\t\t\tExpectedColor: badgeColorHexGreat,\n\t\t},\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  150,\n\t\t\tExpectedColor: badgeColorHexGreat,\n\t\t},\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  201,\n\t\t\tExpectedColor: badgeColorHexGood,\n\t\t},\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  300,\n\t\t\tExpectedColor: badgeColorHexGood,\n\t\t},\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  301,\n\t\t\tExpectedColor: badgeColorHexPassable,\n\t\t},\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  450,\n\t\t\tExpectedColor: badgeColorHexPassable,\n\t\t},\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  700,\n\t\t\tExpectedColor: badgeColorHexBad,\n\t\t},\n\t\t{\n\t\t\tKey:           firstTestEndpoint.Key(),\n\t\t\tResponseTime:  1500,\n\t\t\tExpectedColor: badgeColorHexVeryBad,\n\t\t},\n\t\t{\n\t\t\tKey:           secondTestEndpoint.Key(),\n\t\t\tResponseTime:  50,\n\t\t\tExpectedColor: badgeColorHexAwesome,\n\t\t},\n\t\t{\n\t\t\tKey:           secondTestEndpoint.Key(),\n\t\t\tResponseTime:  1500,\n\t\t\tExpectedColor: badgeColorHexPassable,\n\t\t},\n\t\t{\n\t\t\tKey:           secondTestEndpoint.Key(),\n\t\t\tResponseTime:  2222,\n\t\t\tExpectedColor: badgeColorHexBad,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Key+\"-response-time-\"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {\n\t\t\tif getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg) != scenario.ExpectedColor {\n\t\t\t\tt.Errorf(\"expected %s from %d, got %v\", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetBadgeColorFromHealth(t *testing.T) {\n\tscenarios := []struct {\n\t\tHealthStatus  string\n\t\tExpectedColor string\n\t}{\n\t\t{\n\t\t\tHealthStatus:  HealthStatusUp,\n\t\t\tExpectedColor: badgeColorHexAwesome,\n\t\t},\n\t\t{\n\t\t\tHealthStatus:  HealthStatusDown,\n\t\t\tExpectedColor: badgeColorHexVeryBad,\n\t\t},\n\t\t{\n\t\t\tHealthStatus:  HealthStatusUnknown,\n\t\t\tExpectedColor: badgeColorHexPassable,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(\"health-\"+scenario.HealthStatus, func(t *testing.T) {\n\t\t\tif getBadgeColorFromHealth(scenario.HealthStatus) != scenario.ExpectedColor {\n\t\t\t\tt.Errorf(\"expected %s from %s, got %v\", scenario.ExpectedColor, scenario.HealthStatus, getBadgeColorFromHealth(scenario.HealthStatus))\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "api/cache.go",
    "content": "package api\n\nimport (\n\t\"time\"\n\n\t\"github.com/TwiN/gocache/v2\"\n)\n\nconst (\n\tcacheTTL = 10 * time.Second\n)\n\nvar (\n\tcache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)\n)\n"
  },
  {
    "path": "api/chart.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"math\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common\"\n\t\"github.com/TwiN/logr\"\n\t\"github.com/gofiber/fiber/v2\"\n\t\"github.com/wcharczuk/go-chart/v2\"\n\t\"github.com/wcharczuk/go-chart/v2/drawing\"\n)\n\nconst timeFormat = \"3:04PM\"\n\nvar (\n\tgridStyle = chart.Style{\n\t\tStrokeColor: drawing.Color{R: 119, G: 119, B: 119, A: 40},\n\t\tStrokeWidth: 1.0,\n\t}\n\taxisStyle = chart.Style{\n\t\tFontColor: drawing.Color{R: 119, G: 119, B: 119, A: 255},\n\t}\n\ttransparentStyle = chart.Style{\n\t\tFillColor: drawing.Color{R: 255, G: 255, B: 255, A: 0},\n\t}\n)\n\nfunc ResponseTimeChart(c *fiber.Ctx) error {\n\tduration := c.Params(\"duration\")\n\tchartTimestampFormatter := chart.TimeValueFormatterWithFormat(timeFormat)\n\tvar from time.Time\n\tswitch duration {\n\tcase \"30d\":\n\t\tfrom = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)\n\t\tchartTimestampFormatter = chart.TimeDateValueFormatter\n\tcase \"7d\":\n\t\tfrom = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)\n\tcase \"24h\":\n\t\tfrom = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)\n\tdefault:\n\t\treturn c.Status(400).SendString(\"Durations supported: 30d, 7d, 24h\")\n\t}\n\tkey, err := url.QueryUnescape(c.Params(\"key\"))\n\tif err != nil {\n\t\treturn c.Status(400).SendString(\"invalid key encoding\")\n\t}\n\thourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(key, from, time.Now())\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t} else if errors.Is(err, common.ErrInvalidTimeRange) {\n\t\t\treturn c.Status(400).SendString(err.Error())\n\t\t}\n\t\treturn c.Status(500).SendString(err.Error())\n\t}\n\tif len(hourlyAverageResponseTime) == 0 {\n\t\treturn c.Status(204).SendString(\"\")\n\t}\n\tseries := chart.TimeSeries{\n\t\tName: \"Average response time per hour\",\n\t\tStyle: chart.Style{\n\t\t\tStrokeWidth: 1.5,\n\t\t\tDotWidth:    2.0,\n\t\t},\n\t}\n\tkeys := make([]int, 0, len(hourlyAverageResponseTime))\n\tearliestTimestamp := int64(0)\n\tfor hourlyTimestamp := range hourlyAverageResponseTime {\n\t\tkeys = append(keys, int(hourlyTimestamp))\n\t\tif earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {\n\t\t\tearliestTimestamp = hourlyTimestamp\n\t\t}\n\t}\n\tfor earliestTimestamp > from.Unix() {\n\t\tearliestTimestamp -= int64(time.Hour.Seconds())\n\t\tkeys = append(keys, int(earliestTimestamp))\n\t}\n\tsort.Ints(keys)\n\tvar maxAverageResponseTime float64\n\tfor _, key := range keys {\n\t\taverageResponseTime := float64(hourlyAverageResponseTime[int64(key)])\n\t\tif maxAverageResponseTime < averageResponseTime {\n\t\t\tmaxAverageResponseTime = averageResponseTime\n\t\t}\n\t\tseries.XValues = append(series.XValues, time.Unix(int64(key), 0))\n\t\tseries.YValues = append(series.YValues, averageResponseTime)\n\t}\n\tgraph := chart.Chart{\n\t\tCanvas:     transparentStyle,\n\t\tBackground: transparentStyle,\n\t\tWidth:      1280,\n\t\tHeight:     300,\n\t\tXAxis: chart.XAxis{\n\t\t\tValueFormatter: chartTimestampFormatter,\n\t\t\tGridMajorStyle: gridStyle,\n\t\t\tGridMinorStyle: gridStyle,\n\t\t\tStyle:          axisStyle,\n\t\t\tNameStyle:      axisStyle,\n\t\t},\n\t\tYAxis: chart.YAxis{\n\t\t\tName:           \"Average response time\",\n\t\t\tGridMajorStyle: gridStyle,\n\t\t\tGridMinorStyle: gridStyle,\n\t\t\tStyle:          axisStyle,\n\t\t\tNameStyle:      axisStyle,\n\t\t\tRange: &chart.ContinuousRange{\n\t\t\t\tMin: 0,\n\t\t\t\tMax: math.Ceil(maxAverageResponseTime * 1.25),\n\t\t\t},\n\t\t},\n\t\tSeries: []chart.Series{series},\n\t}\n\tc.Set(\"Content-Type\", \"image/svg+xml\")\n\tc.Set(\"Cache-Control\", \"no-cache, no-store\")\n\tc.Set(\"Expires\", \"0\")\n\tc.Status(http.StatusOK)\n\tif err := graph.Render(chart.SVG, c); err != nil {\n\t\tlogr.Errorf(\"[api.ResponseTimeChart] Failed to render response time chart: %s\", err.Error())\n\t\treturn c.Status(500).SendString(err.Error())\n\t}\n\treturn nil\n}\n\nfunc ResponseTimeHistory(c *fiber.Ctx) error {\n\tduration := c.Params(\"duration\")\n\tvar from time.Time\n\tswitch duration {\n\tcase \"30d\":\n\t\tfrom = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)\n\tcase \"7d\":\n\t\tfrom = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)\n\tcase \"24h\":\n\t\tfrom = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)\n\tdefault:\n\t\treturn c.Status(400).SendString(\"Durations supported: 30d, 7d, 24h\")\n\t}\n\tendpointKey, err := url.QueryUnescape(c.Params(\"key\"))\n\tif err != nil {\n\t\treturn c.Status(400).SendString(\"invalid key encoding\")\n\t}\n\thourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(endpointKey, from, time.Now())\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t}\n\t\tif errors.Is(err, common.ErrInvalidTimeRange) {\n\t\t\treturn c.Status(400).SendString(err.Error())\n\t\t}\n\t\treturn c.Status(500).SendString(err.Error())\n\t}\n\tif len(hourlyAverageResponseTime) == 0 {\n\t\treturn c.Status(200).JSON(map[string]interface{}{\n\t\t\t\"timestamps\": []int64{},\n\t\t\t\"values\":     []int{},\n\t\t})\n\t}\n\thourlyTimestamps := make([]int, 0, len(hourlyAverageResponseTime))\n\tearliestTimestamp := int64(0)\n\tfor hourlyTimestamp := range hourlyAverageResponseTime {\n\t\thourlyTimestamps = append(hourlyTimestamps, int(hourlyTimestamp))\n\t\tif earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {\n\t\t\tearliestTimestamp = hourlyTimestamp\n\t\t}\n\t}\n\tfor earliestTimestamp > from.Unix() {\n\t\tearliestTimestamp -= int64(time.Hour.Seconds())\n\t\thourlyTimestamps = append(hourlyTimestamps, int(earliestTimestamp))\n\t}\n\tsort.Ints(hourlyTimestamps)\n\ttimestamps := make([]int64, 0, len(hourlyTimestamps))\n\tvalues := make([]int, 0, len(hourlyTimestamps))\n\tfor _, hourlyTimestamp := range hourlyTimestamps {\n\t\ttimestamp := int64(hourlyTimestamp)\n\t\taverageResponseTime := hourlyAverageResponseTime[timestamp]\n\t\ttimestamps = append(timestamps, timestamp*1000)\n\t\tvalues = append(values, averageResponseTime)\n\t}\n\treturn c.Status(http.StatusOK).JSON(map[string]interface{}{\n\t\t\"timestamps\": timestamps,\n\t\t\"values\":     values,\n\t})\n}\n"
  },
  {
    "path": "api/chart_test.go",
    "content": "package api\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/watchdog\"\n)\n\nfunc TestResponseTimeChart(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tcfg := &config.Config{\n\t\tMetrics: true,\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName:  \"frontend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:  \"backend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t},\n\t}\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})\n\tapi := New(cfg)\n\trouter := api.Router()\n\ttype Scenario struct {\n\t\tName         string\n\t\tPath         string\n\t\tExpectedCode int\n\t\tGzip         bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:         \"chart-response-time-24h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/24h/chart.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"chart-response-time-7d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/7d/chart.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"chart-response-time-30d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/30d/chart.svg\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"chart-response-time-with-invalid-duration\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/3d/chart.svg\",\n\t\t\tExpectedCode: http.StatusBadRequest,\n\t\t},\n\t\t{\n\t\t\tName:         \"chart-response-time-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/endpoints/invalid_key/response-times/7d/chart.svg\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tif scenario.Gzip {\n\t\t\t\trequest.Header.Set(\"Accept-Encoding\", \"gzip\")\n\t\t\t}\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResponseTimeHistory(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tcfg := &config.Config{\n\t\tMetrics: true,\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName:  \"frontend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:  \"backend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t},\n\t}\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})\n\tapi := New(cfg)\n\trouter := api.Router()\n\ttype Scenario struct {\n\t\tName         string\n\t\tPath         string\n\t\tExpectedCode int\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:         \"history-response-time-24h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/24h/history\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"history-response-time-7d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/7d/history\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"history-response-time-30d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/30d/history\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"history-response-time-with-invalid-duration\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/3d/history\",\n\t\t\tExpectedCode: http.StatusBadRequest,\n\t\t},\n\t\t{\n\t\t\tName:         \"history-response-time-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/endpoints/invalid_key/response-times/7d/history\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "api/config.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/security\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\ntype ConfigHandler struct {\n\tsecurityConfig *security.Config\n\tconfig         *config.Config\n}\n\nfunc (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {\n\thasOIDC := false\n\tisAuthenticated := true // Default to true if no security config is set\n\tif handler.securityConfig != nil {\n\t\thasOIDC = handler.securityConfig.OIDC != nil\n\t\tisAuthenticated = handler.securityConfig.IsAuthenticated(c)\n\t}\n\n\t// Prepare response with announcements\n\tresponse := map[string]interface{}{\n\t\t\"oidc\":          hasOIDC,\n\t\t\"authenticated\": isAuthenticated,\n\t}\n\t// Add announcements if available, otherwise use empty slice\n\tif handler.config != nil && handler.config.Announcements != nil && len(handler.config.Announcements) > 0 {\n\t\tresponse[\"announcements\"] = handler.config.Announcements\n\t} else {\n\t\tresponse[\"announcements\"] = []interface{}{}\n\t}\n\n\t// Return the config as JSON\n\tc.Set(\"Content-Type\", \"application/json\")\n\tresponseBytes, err := json.Marshal(response)\n\tif err != nil {\n\t\treturn c.Status(500).SendString(fmt.Sprintf(`{\"error\":\"Failed to marshal response: %s\"}`, err.Error()))\n\t}\n\treturn c.Status(200).Send(responseBytes)\n}\n"
  },
  {
    "path": "api/config_test.go",
    "content": "package api\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/security\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\nfunc TestConfigHandler_ServeHTTP(t *testing.T) {\n\tsecurityConfig := &security.Config{\n\t\tOIDC: &security.OIDCConfig{\n\t\t\tIssuerURL:       \"https://sso.gatus.io/\",\n\t\t\tRedirectURL:     \"http://localhost:80/authorization-code/callback\",\n\t\t\tScopes:          []string{\"openid\"},\n\t\t\tAllowedSubjects: []string{\"user1@example.com\"},\n\t\t},\n\t}\n\thandler := ConfigHandler{securityConfig: securityConfig}\n\t// Create a fake router. We're doing this because I need the gate to be initialized.\n\tapp := fiber.New()\n\tapp.Get(\"/api/v1/config\", handler.GetConfig)\n\terr := securityConfig.ApplySecurityMiddleware(app)\n\tif err != nil {\n\t\tt.Error(\"expected err to be nil, but was\", err)\n\t}\n\t// Test the config handler\n\trequest, _ := http.NewRequest(\"GET\", \"/api/v1/config\", http.NoBody)\n\tresponse, err := app.Test(request)\n\tif err != nil {\n\t\tt.Error(\"expected err to be nil, but was\", err)\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode != http.StatusOK {\n\t\tt.Error(\"expected code to be 200, but was\", response.StatusCode)\n\t}\n\tbody, err := io.ReadAll(response.Body)\n\tif err != nil {\n\t\tt.Error(\"expected err to be nil, but was\", err)\n\t}\n\tif string(body) != `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}` {\n\t\tt.Error(\"expected body to be `{\\\"announcements\\\":[],\\\"authenticated\\\":false,\\\"oidc\\\":true}`, but was\", string(body))\n\t}\n}\n"
  },
  {
    "path": "api/custom_css.go",
    "content": "package api\n\nimport (\n\t\"github.com/gofiber/fiber/v2\"\n)\n\ntype CustomCSSHandler struct {\n\tcustomCSS string\n}\n\nfunc (handler CustomCSSHandler) GetCustomCSS(c *fiber.Ctx) error {\n\tc.Set(\"Content-Type\", \"text/css\")\n\treturn c.Status(200).SendString(handler.customCSS)\n}\n"
  },
  {
    "path": "api/endpoint_status.go",
    "content": "package api\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/remote\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n\t\"github.com/TwiN/logr\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\n// EndpointStatuses handles requests to retrieve all EndpointStatus\n// Due to how intensive this operation can be on the storage, this function leverages a cache.\nfunc EndpointStatuses(cfg *config.Config) fiber.Handler {\n\treturn func(c *fiber.Ctx) error {\n\t\tpage, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)\n\t\tvalue, exists := cache.Get(fmt.Sprintf(\"endpoint-status-%d-%d\", page, pageSize))\n\t\tvar data []byte\n\t\tif !exists {\n\t\t\tendpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s\", err.Error())\n\t\t\t\treturn c.Status(500).SendString(err.Error())\n\t\t\t}\n\t\t\t// ALPHA: Retrieve endpoint statuses from remote instances\n\t\t\tif endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {\n\t\t\t\tlogr.Errorf(\"[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s\", err.Error())\n\t\t\t} else if endpointStatusesFromRemote != nil {\n\t\t\t\tendpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)\n\t\t\t}\n\t\t\t// Marshal endpoint statuses to JSON\n\t\t\tdata, err = json.Marshal(endpointStatuses)\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[api.EndpointStatuses] Unable to marshal object to JSON: %s\", err.Error())\n\t\t\t\treturn c.Status(500).SendString(\"unable to marshal object to JSON\")\n\t\t\t}\n\t\t\tcache.SetWithTTL(fmt.Sprintf(\"endpoint-status-%d-%d\", page, pageSize), data, cacheTTL)\n\t\t} else {\n\t\t\tdata = value.([]byte)\n\t\t}\n\t\tc.Set(\"Content-Type\", \"application/json\")\n\t\treturn c.Status(200).Send(data)\n\t}\n}\n\nfunc getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*endpoint.Status, error) {\n\tif remoteConfig == nil || len(remoteConfig.Instances) == 0 {\n\t\treturn nil, nil\n\t}\n\tvar endpointStatusesFromAllRemotes []*endpoint.Status\n\thttpClient := client.GetHTTPClient(remoteConfig.ClientConfig)\n\tfor _, instance := range remoteConfig.Instances {\n\t\tresponse, err := httpClient.Get(instance.URL)\n\t\tif err != nil {\n\t\t\t// Log the error but continue with other instances\n\t\t\tlogr.Errorf(\"[api.getEndpointStatusesFromRemoteInstances] Failed to retrieve endpoint statuses from %s: %s\", instance.URL, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tvar endpointStatuses []*endpoint.Status\n\t\tif err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {\n\t\t\t_ = response.Body.Close()\n\t\t\tlogr.Errorf(\"[api.getEndpointStatusesFromRemoteInstances] Failed to decode endpoint statuses from %s: %s\", instance.URL, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\t_ = response.Body.Close()\n\t\tfor _, endpointStatus := range endpointStatuses {\n\t\t\tendpointStatus.Name = instance.EndpointPrefix + endpointStatus.Name\n\t\t}\n\t\tendpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)\n\t}\n\t// Only return nil, error if no remote instances were successfully processed\n\tif len(endpointStatusesFromAllRemotes) == 0 && remoteConfig.Instances != nil {\n\t\treturn nil, fmt.Errorf(\"failed to retrieve endpoint statuses from all remote instances\")\n\t}\n\treturn endpointStatusesFromAllRemotes, nil\n}\n\n// EndpointStatus retrieves a single endpoint.Status by group and endpoint name\nfunc EndpointStatus(cfg *config.Config) fiber.Handler {\n\treturn func(c *fiber.Ctx) error {\n\t\tpage, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)\n\t\tkey, err := url.QueryUnescape(c.Params(\"key\"))\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[api.EndpointStatus] Failed to decode key: %s\", err.Error())\n\t\t\treturn c.Status(400).SendString(\"invalid key encoding\")\n\t\t}\n\t\tendpointStatus, err := store.Get().GetEndpointStatusByKey(key, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))\n\t\tif err != nil {\n\t\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t\t}\n\t\t\tlogr.Errorf(\"[api.EndpointStatus] Failed to retrieve endpoint status: %s\", err.Error())\n\t\t\treturn c.Status(500).SendString(err.Error())\n\t\t}\n\t\tif endpointStatus == nil { // XXX: is this check necessary?\n\t\t\tlogr.Errorf(\"[api.EndpointStatus] Endpoint with key=%s not found\", key)\n\t\t\treturn c.Status(404).SendString(\"not found\")\n\t\t}\n\t\toutput, err := json.Marshal(endpointStatus)\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[api.EndpointStatus] Unable to marshal object to JSON: %s\", err.Error())\n\t\t\treturn c.Status(500).SendString(\"unable to marshal object to JSON\")\n\t\t}\n\t\tc.Set(\"Content-Type\", \"application/json\")\n\t\treturn c.Status(200).Send(output)\n\t}\n}\n"
  },
  {
    "path": "api/endpoint_status_test.go",
    "content": "package api\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/watchdog\"\n)\n\nvar (\n\ttimestamp = time.Now()\n\n\ttestEndpoint = endpoint.Endpoint{\n\t\tName:                    \"name\",\n\t\tGroup:                   \"group\",\n\t\tURL:                     \"https://example.org/what/ever\",\n\t\tMethod:                  \"GET\",\n\t\tBody:                    \"body\",\n\t\tInterval:                30 * time.Second,\n\t\tConditions:              []endpoint.Condition{endpoint.Condition(\"[STATUS] == 200\"), endpoint.Condition(\"[RESPONSE_TIME] < 500\"), endpoint.Condition(\"[CERTIFICATE_EXPIRATION] < 72h\")},\n\t\tAlerts:                  nil,\n\t\tNumberOfFailuresInARow:  0,\n\t\tNumberOfSuccessesInARow: 0,\n\t}\n\ttestSuccessfulResult = endpoint.Result{\n\t\tHostname:              \"example.org\",\n\t\tIP:                    \"127.0.0.1\",\n\t\tHTTPStatus:            200,\n\t\tErrors:                nil,\n\t\tConnected:             true,\n\t\tSuccess:               true,\n\t\tTimestamp:             timestamp,\n\t\tDuration:              150 * time.Millisecond,\n\t\tCertificateExpiration: 10 * time.Hour,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{\n\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[CERTIFICATE_EXPIRATION] < 72h\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t},\n\t}\n\ttestUnsuccessfulResult = endpoint.Result{\n\t\tHostname:              \"example.org\",\n\t\tIP:                    \"127.0.0.1\",\n\t\tHTTPStatus:            200,\n\t\tErrors:                []string{\"error-1\", \"error-2\"},\n\t\tConnected:             true,\n\t\tSuccess:               false,\n\t\tTimestamp:             timestamp,\n\t\tDuration:              750 * time.Millisecond,\n\t\tCertificateExpiration: 10 * time.Hour,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{\n\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\tSuccess:   false,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[CERTIFICATE_EXPIRATION] < 72h\",\n\t\t\t\tSuccess:   false,\n\t\t\t},\n\t\t},\n\t}\n)\n\nfunc TestEndpointStatus(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tcfg := &config.Config{\n\t\tMetrics: true,\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName:  \"frontend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:  \"backend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t},\n\t\tStorage: &storage.Config{\n\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t},\n\t}\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})\n\tapi := New(cfg)\n\trouter := api.Router()\n\ttype Scenario struct {\n\t\tName         string\n\t\tPath         string\n\t\tExpectedCode int\n\t\tGzip         bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:         \"endpoint-status\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/statuses\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"endpoint-status-gzip\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/statuses\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tGzip:         true,\n\t\t},\n\t\t{\n\t\t\tName:         \"endpoint-status-pagination\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/statuses?page=1&pageSize=20\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"endpoint-status-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/endpoints/invalid_key/statuses\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tif scenario.Gzip {\n\t\t\t\trequest.Header.Set(\"Accept-Encoding\", \"gzip\")\n\t\t\t}\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEndpointStatuses(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tfirstResult := &testSuccessfulResult\n\tsecondResult := &testUnsuccessfulResult\n\tstore.Get().InsertEndpointResult(&testEndpoint, firstResult)\n\tstore.Get().InsertEndpointResult(&testEndpoint, secondResult)\n\t// Can't be bothered dealing with timezone issues on the worker that runs the automated tests\n\tfirstResult.Timestamp = time.Time{}\n\tsecondResult.Timestamp = time.Time{}\n\tapi := New(&config.Config{\n\t\tMetrics: true,\n\t\tStorage: &storage.Config{\n\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t},\n\t})\n\trouter := api.Router()\n\ttype Scenario struct {\n\t\tName         string\n\t\tPath         string\n\t\tExpectedCode int\n\t\tExpectedBody string\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:         \"no-pagination\",\n\t\t\tPath:         \"/api/v1/endpoints/statuses\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"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\"}]}]`,\n\t\t},\n\t\t{\n\t\t\tName:         \"pagination-first-result\",\n\t\t\tPath:         \"/api/v1/endpoints/statuses?page=1&pageSize=1\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"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\"}]}]`,\n\t\t},\n\t\t{\n\t\t\tName:         \"pagination-second-result\",\n\t\t\tPath:         \"/api/v1/endpoints/statuses?page=2&pageSize=1\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"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\"}]}]`,\n\t\t},\n\t\t{\n\t\t\tName:         \"pagination-no-results\",\n\t\t\tPath:         \"/api/v1/endpoints/statuses?page=5&pageSize=20\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"name\":\"name\",\"group\":\"group\",\"key\":\"group_name\",\"results\":[]}]`,\n\t\t},\n\t\t{\n\t\t\tName:         \"invalid-pagination-should-fall-back-to-default\",\n\t\t\tPath:         \"/api/v1/endpoints/statuses?page=INVALID&pageSize=INVALID\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"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\"}]}]`,\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer response.Body.Close()\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t\tbody, err := io.ReadAll(response.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected err to be nil, but was\", err)\n\t\t\t}\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n %s\\n\\ngot:\\n %s\", scenario.ExpectedBody, string(body))\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "api/external_endpoint.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/metrics\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common\"\n\t\"github.com/TwiN/gatus/v5/watchdog\"\n\t\"github.com/TwiN/logr\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\nfunc CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {\n\textraLabels := cfg.GetUniqueExtraMetricLabels()\n\treturn func(c *fiber.Ctx) error {\n\t\t// Check if the success query parameter is present\n\t\tsuccess, exists := c.Queries()[\"success\"]\n\t\tif !exists || (success != \"true\" && success != \"false\") {\n\t\t\treturn c.Status(400).SendString(\"missing or invalid success query parameter\")\n\t\t}\n\t\t// Check if the authorization bearer token header is correct\n\t\tauthorizationHeader := string(c.Request().Header.Peek(\"Authorization\"))\n\t\tif !strings.HasPrefix(authorizationHeader, \"Bearer \") {\n\t\t\treturn c.Status(401).SendString(\"invalid Authorization header\")\n\t\t}\n\t\ttoken := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, \"Bearer \"))\n\t\tif len(token) == 0 {\n\t\t\treturn c.Status(401).SendString(\"bearer token must not be empty\")\n\t\t}\n\t\tkey := c.Params(\"key\")\n\t\texternalEndpoint := cfg.GetExternalEndpointByKey(key)\n\t\tif externalEndpoint == nil {\n\t\t\tlogr.Errorf(\"[api.CreateExternalEndpointResult] External endpoint with key=%s not found\", key)\n\t\t\treturn c.Status(404).SendString(\"not found\")\n\t\t}\n\t\tif externalEndpoint.Token != token {\n\t\t\tlogr.Errorf(\"[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s\", key)\n\t\t\treturn c.Status(401).SendString(\"invalid token\")\n\t\t}\n\t\t// Persist the result in the storage\n\t\tresult := &endpoint.Result{\n\t\t\tTimestamp: time.Now(),\n\t\t\tSuccess:   c.QueryBool(\"success\"),\n\t\t\tErrors:    []string{},\n\t\t}\n\t\tif len(c.Query(\"duration\")) > 0 {\n\t\t\tparsedDuration, err := time.ParseDuration(c.Query(\"duration\"))\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[api.CreateExternalEndpointResult] Invalid duration from string=%s with error: %s\", c.Query(\"duration\"), err.Error())\n\t\t\t\treturn c.Status(400).SendString(\"invalid duration: \" + err.Error())\n\t\t\t}\n\t\t\tresult.Duration = parsedDuration\n\t\t}\n\t\tif errorFromQuery := c.Query(\"error\"); !result.Success && len(errorFromQuery) > 0 {\n\t\t\tresult.AddError(errorFromQuery)\n\t\t}\n\t\tconvertedEndpoint := externalEndpoint.ToEndpoint()\n\t\tif err := store.Get().InsertEndpointResult(convertedEndpoint, result); err != nil {\n\t\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t\t}\n\t\t\tlogr.Errorf(\"[api.CreateExternalEndpointResult] Failed to insert result in storage: %s\", err.Error())\n\t\t\treturn c.Status(500).SendString(err.Error())\n\t\t}\n\t\tlogr.Infof(\"[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s\", c.Params(\"key\"), success)\n\t\tinEndpointMaintenanceWindow := false\n\t\tfor _, maintenanceWindow := range externalEndpoint.MaintenanceWindows {\n\t\t\tif maintenanceWindow.IsUnderMaintenance() {\n\t\t\t\tlogr.Debug(\"[api.CreateExternalEndpointResult] Under endpoint maintenance window\")\n\t\t\t\tinEndpointMaintenanceWindow = true\n\t\t\t}\n\t\t}\n\t\t// Check if an alert should be triggered or resolved\n\t\tif !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {\n\t\t\twatchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)\n\t\t\texternalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow\n\t\t\texternalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow\n\t\t} else {\n\t\t\tlogr.Debug(\"[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window\")\n\t\t}\n\t\tif cfg.Metrics {\n\t\t\tmetrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)\n\t\t}\n\t\t// Return the result\n\t\treturn c.Status(200).SendString(\"\")\n\t}\n}\n"
  },
  {
    "path": "api/external_endpoint_test.go",
    "content": "package api\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting\"\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/discord\"\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/maintenance\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n)\n\nfunc TestCreateExternalEndpointResult(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tcfg := &config.Config{\n\t\tAlerting: &alerting.Config{\n\t\t\tDiscord: &discord.AlertProvider{},\n\t\t},\n\t\tExternalEndpoints: []*endpoint.ExternalEndpoint{\n\t\t\t{\n\t\t\t\tName:  \"n\",\n\t\t\t\tGroup: \"g\",\n\t\t\t\tToken: \"token\",\n\t\t\t\tAlerts: []*alert.Alert{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:             alert.TypeDiscord,\n\t\t\t\t\t\tFailureThreshold: 2,\n\t\t\t\t\t\tSuccessThreshold: 2,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tMaintenance: &maintenance.Config{},\n\t}\n\tapi := New(cfg)\n\trouter := api.Router()\n\tscenarios := []struct {\n\t\tName                           string\n\t\tPath                           string\n\t\tAuthorizationHeaderBearerToken string\n\t\tExpectedCode                   int\n\t}{\n\t\t{\n\t\t\tName:                           \"no-token\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=true\",\n\t\t\tAuthorizationHeaderBearerToken: \"\",\n\t\t\tExpectedCode:                   401,\n\t\t},\n\t\t{\n\t\t\tName:                           \"bad-token\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=true\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer bad-token\",\n\t\t\tExpectedCode:                   401,\n\t\t},\n\t\t{\n\t\t\tName:                           \"bad-key\",\n\t\t\tPath:                           \"/api/v1/endpoints/bad_key/external?success=true\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer token\",\n\t\t\tExpectedCode:                   404,\n\t\t},\n\t\t{\n\t\t\tName:                           \"bad-success-value\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=invalid\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer token\",\n\t\t\tExpectedCode:                   400,\n\t\t},\n\t\t{\n\t\t\tName:                           \"bad-duration-value\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=true&duration=invalid\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer token\",\n\t\t\tExpectedCode:                   400,\n\t\t},\n\t\t{\n\t\t\tName:                           \"good-token-success-true\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=true\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer token\",\n\t\t\tExpectedCode:                   200,\n\t\t},\n\t\t{\n\t\t\tName:                           \"good-token-success-true-with-ignored-error-because-success-true\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=true&error=failed\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer token\",\n\t\t\tExpectedCode:                   200,\n\t\t},\n\t\t{\n\t\t\tName:                           \"good-duration-success-true\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=true&duration=10s\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer token\",\n\t\t\tExpectedCode:                   200,\n\t\t},\n\t\t{\n\t\t\tName:                           \"good-token-success-false\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=false\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer token\",\n\t\t\tExpectedCode:                   200,\n\t\t},\n\t\t{\n\t\t\tName:                           \"good-token-success-false-again\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=false\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer token\",\n\t\t\tExpectedCode:                   200,\n\t\t},\n\t\t{\n\t\t\tName:                           \"good-token-success-false-with-error\",\n\t\t\tPath:                           \"/api/v1/endpoints/g_n/external?success=false&error=failed\",\n\t\t\tAuthorizationHeaderBearerToken: \"Bearer token\",\n\t\t\tExpectedCode:                   200,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\trequest := httptest.NewRequest(\"POST\", scenario.Path, http.NoBody)\n\t\t\tif len(scenario.AuthorizationHeaderBearerToken) > 0 {\n\t\t\t\trequest.Header.Set(\"Authorization\", scenario.AuthorizationHeaderBearerToken)\n\t\t\t}\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer response.Body.Close()\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t})\n\t}\n\tt.Run(\"verify-end-results\", func(t *testing.T) {\n\t\tendpointStatus, err := store.Get().GetEndpointStatus(\"g\", \"n\", paging.NewEndpointStatusParams().WithResults(1, 11))\n\t\tif err != nil {\n\t\t\tt.Errorf(\"failed to get endpoint status: %s\", err.Error())\n\t\t\treturn\n\t\t}\n\t\tif endpointStatus.Key != \"g_n\" {\n\t\t\tt.Errorf(\"expected key to be g_n but got %s\", endpointStatus.Key)\n\t\t}\n\t\tif len(endpointStatus.Results) != 6 {\n\t\t\tt.Errorf(\"expected 6 results but got %d\", len(endpointStatus.Results))\n\t\t}\n\t\tif !endpointStatus.Results[0].Success {\n\t\t\tt.Errorf(\"expected first result to be successful\")\n\t\t}\n\t\tif !endpointStatus.Results[1].Success {\n\t\t\tt.Errorf(\"expected second result to be successful\")\n\t\t}\n\t\tif len(endpointStatus.Results[1].Errors) > 0 {\n\t\t\tt.Errorf(\"expected second result to have no errors\")\n\t\t}\n\t\tif endpointStatus.Results[2].Duration == 0 || endpointStatus.Results[2].Duration.Seconds() != 10 {\n\t\t\tt.Errorf(\"expected third result to have a duration of 10 seconds\")\n\t\t}\n\t\tif endpointStatus.Results[3].Success {\n\t\t\tt.Errorf(\"expected fourth result to be unsuccessful\")\n\t\t}\n\t\tif endpointStatus.Results[4].Success {\n\t\t\tt.Errorf(\"expected fifth result to be unsuccessful\")\n\t\t}\n\t\tif endpointStatus.Results[5].Success {\n\t\t\tt.Errorf(\"expected sixth result to be unsuccessful\")\n\t\t}\n\t\tif len(endpointStatus.Results[5].Errors) == 0 || endpointStatus.Results[5].Errors[0] != \"failed\" {\n\t\t\tt.Errorf(\"expected sixth result to have errors: failed\")\n\t\t}\n\t\texternalEndpointFromConfig := cfg.GetExternalEndpointByKey(\"g_n\")\n\t\tif externalEndpointFromConfig.NumberOfFailuresInARow != 3 {\n\t\t\tt.Errorf(\"expected 3 failures in a row but got %d\", externalEndpointFromConfig.NumberOfFailuresInARow)\n\t\t}\n\t\tif externalEndpointFromConfig.NumberOfSuccessesInARow != 0 {\n\t\t\tt.Errorf(\"expected 0 successes in a row but got %d\", externalEndpointFromConfig.NumberOfSuccessesInARow)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "api/raw.go",
    "content": "package api\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\nfunc UptimeRaw(c *fiber.Ctx) error {\n\tduration := c.Params(\"duration\")\n\tvar from time.Time\n\tswitch duration {\n\tcase \"30d\":\n\t\tfrom = time.Now().Add(-30 * 24 * time.Hour)\n\tcase \"7d\":\n\t\tfrom = time.Now().Add(-7 * 24 * time.Hour)\n\tcase \"24h\":\n\t\tfrom = time.Now().Add(-24 * time.Hour)\n\tcase \"1h\":\n\t\tfrom = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little\n\tdefault:\n\t\treturn c.Status(400).SendString(\"Durations supported: 30d, 7d, 24h, 1h\")\n\t}\n\tkey, err := url.QueryUnescape(c.Params(\"key\"))\n\tif err != nil {\n\t\treturn c.Status(400).SendString(\"invalid key encoding\")\n\t}\n\tuptime, err := store.Get().GetUptimeByKey(key, from, time.Now())\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t} else if errors.Is(err, common.ErrInvalidTimeRange) {\n\t\t\treturn c.Status(400).SendString(err.Error())\n\t\t}\n\t\treturn c.Status(500).SendString(err.Error())\n\t}\n\n\tc.Set(\"Content-Type\", \"text/plain\")\n\tc.Set(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n\tc.Set(\"Expires\", \"0\")\n\treturn c.Status(200).Send([]byte(fmt.Sprintf(\"%f\", uptime)))\n}\n\nfunc ResponseTimeRaw(c *fiber.Ctx) error {\n\tduration := c.Params(\"duration\")\n\tvar from time.Time\n\tswitch duration {\n\tcase \"30d\":\n\t\tfrom = time.Now().Add(-30 * 24 * time.Hour)\n\tcase \"7d\":\n\t\tfrom = time.Now().Add(-7 * 24 * time.Hour)\n\tcase \"24h\":\n\t\tfrom = time.Now().Add(-24 * time.Hour)\n\tcase \"1h\":\n\t\tfrom = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little\n\tdefault:\n\t\treturn c.Status(400).SendString(\"Durations supported: 30d, 7d, 24h, 1h\")\n\t}\n\tkey, err := url.QueryUnescape(c.Params(\"key\"))\n\tif err != nil {\n\t\treturn c.Status(400).SendString(\"invalid key encoding\")\n\t}\n\tresponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\treturn c.Status(404).SendString(err.Error())\n\t\t} else if errors.Is(err, common.ErrInvalidTimeRange) {\n\t\t\treturn c.Status(400).SendString(err.Error())\n\t\t}\n\t\treturn c.Status(500).SendString(err.Error())\n\t}\n\n\tc.Set(\"Content-Type\", \"text/plain\")\n\tc.Set(\"Cache-Control\", \"no-cache, no-store, must-revalidate\")\n\tc.Set(\"Expires\", \"0\")\n\treturn c.Status(200).Send([]byte(fmt.Sprintf(\"%d\", responseTime)))\n}\n"
  },
  {
    "path": "api/raw_test.go",
    "content": "package api\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/ui\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/watchdog\"\n)\n\nfunc TestRawDataEndpoint(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tcfg := &config.Config{\n\t\tMetrics: true,\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName:  \"frontend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:  \"backend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t},\n\t}\n\n\tcfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()\n\tcfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()\n\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})\n\tapi := New(cfg)\n\trouter := api.Router()\n\ttype Scenario struct {\n\t\tName         string\n\t\tPath         string\n\t\tExpectedCode int\n\t\tGzip         bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:         \"raw-uptime-1h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/uptimes/1h\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-uptime-24h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/uptimes/24h\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-uptime-7d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/uptimes/7d\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-uptime-30d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/uptimes/30d\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-uptime-with-invalid-duration\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/uptimes/3d\",\n\t\t\tExpectedCode: http.StatusBadRequest,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-uptime-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/endpoints/invalid_key/uptimes/7d\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-response-times-1h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/1h\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-response-times-24h\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/24h\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-response-times-7d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/7d\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-response-times-30d\",\n\t\t\tPath:         \"/api/v1/endpoints/core_frontend/response-times/30d\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-response-times-with-invalid-duration\",\n\t\t\tPath:         \"/api/v1/endpoints/core_backend/response-times/3d\",\n\t\t\tExpectedCode: http.StatusBadRequest,\n\t\t},\n\t\t{\n\t\t\tName:         \"raw-response-times-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/endpoints/invalid_key/response-times/7d\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tif scenario.Gzip {\n\t\t\t\trequest.Header.Set(\"Accept-Encoding\", \"gzip\")\n\t\t\t}\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "api/spa.go",
    "content": "package api\n\nimport (\n\t_ \"embed\"\n\t\"html/template\"\n\n\t\"github.com/TwiN/gatus/v5/config/ui\"\n\tstatic \"github.com/TwiN/gatus/v5/web\"\n\t\"github.com/TwiN/logr\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\nfunc SinglePageApplication(uiConfig *ui.Config) fiber.Handler {\n\treturn func(c *fiber.Ctx) error {\n\t\tvd := ui.ViewData{UI: uiConfig}\n\t\t{\n\t\t\tthemeFromCookie := string(c.Request().Header.Cookie(\"theme\"))\n\t\t\tif len(themeFromCookie) > 0 {\n\t\t\t\tif themeFromCookie == \"dark\" {\n\t\t\t\t\tvd.Theme = \"dark\"\n\t\t\t\t}\n\t\t\t} else if uiConfig.IsDarkMode() { // Since there's no theme cookie, we'll rely on ui.DarkMode\n\t\t\t\tvd.Theme = \"dark\"\n\t\t\t}\n\t\t}\n\t\tt, err := template.ParseFS(static.FileSystem, static.IndexPath)\n\t\tif err != nil {\n\t\t\t// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.\n\t\t\tlogr.Errorf(\"[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error: %s\", err.Error())\n\t\t\treturn c.Status(500).SendString(\"Failed to parse template. This should never happen, because the template is validated on start.\")\n\t\t}\n\t\tc.Set(\"Content-Type\", \"text/html\")\n\t\terr = t.Execute(c, vd)\n\t\tif err != nil {\n\t\t\t// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.\n\t\t\tlogr.Errorf(\"[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s\", err.Error())\n\t\t\treturn c.Status(500).SendString(\"Failed to parse template. This should never happen, because the template is validated on start.\")\n\t\t}\n\t\treturn c.SendStatus(200)\n\t}\n}\n"
  },
  {
    "path": "api/spa_test.go",
    "content": "package api\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/ui\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/watchdog\"\n)\n\nfunc TestSinglePageApplication(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tcfg := &config.Config{\n\t\tMetrics: true,\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName:  \"frontend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:  \"backend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t},\n\t\tUI: &ui.Config{\n\t\t\tTitle: \"example-title\",\n\t\t},\n\t}\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})\n\twatchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})\n\tapi := New(cfg)\n\trouter := api.Router()\n\ttype Scenario struct {\n\t\tName              string\n\t\tPath              string\n\t\tGzip              bool\n\t\tCookieDarkMode    bool\n\t\tUIDarkMode        bool\n\t\tExpectedCode      int\n\t\tExpectedDarkTheme bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:              \"frontend-home\",\n\t\t\tPath:              \"/\",\n\t\t\tCookieDarkMode:    true,\n\t\t\tUIDarkMode:        false,\n\t\t\tExpectedDarkTheme: true,\n\t\t\tExpectedCode:      200,\n\t\t},\n\t\t{\n\t\t\tName:              \"frontend-endpoint-light\",\n\t\t\tPath:              \"/endpoints/core_frontend\",\n\t\t\tCookieDarkMode:    false,\n\t\t\tUIDarkMode:        false,\n\t\t\tExpectedDarkTheme: false,\n\t\t\tExpectedCode:      200,\n\t\t},\n\t\t{\n\t\t\tName:              \"frontend-endpoint-dark\",\n\t\t\tPath:              \"/endpoints/core_frontend\",\n\t\t\tCookieDarkMode:    false,\n\t\t\tUIDarkMode:        true,\n\t\t\tExpectedDarkTheme: true,\n\t\t\tExpectedCode:      200,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg.UI.DarkMode = &scenario.UIDarkMode\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tif scenario.Gzip {\n\t\t\t\trequest.Header.Set(\"Accept-Encoding\", \"gzip\")\n\t\t\t}\n\t\t\tif scenario.CookieDarkMode {\n\t\t\t\trequest.Header.Set(\"Cookie\", \"theme=dark\")\n\t\t\t}\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer response.Body.Close()\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t\tbody, _ := io.ReadAll(response.Body)\n\t\t\tstrBody := string(body)\n\t\t\tif !strings.Contains(strBody, cfg.UI.Title) {\n\t\t\t\tt.Errorf(\"%s %s should have contained the title\", request.Method, request.URL)\n\t\t\t}\n\t\t\tif scenario.ExpectedDarkTheme && !strings.Contains(strBody, \"class=\\\"dark\\\"\") {\n\t\t\t\tt.Errorf(\"%s %s should have responded with dark mode headers\", request.Method, request.URL)\n\t\t\t}\n\t\t\tif !scenario.ExpectedDarkTheme && strings.Contains(strBody, \"class=\\\"dark\\\"\") {\n\t\t\t\tt.Errorf(\"%s %s should not have responded with dark mode headers\", request.Method, request.URL)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "api/suite_status.go",
    "content": "package api\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\n// SuiteStatuses handles requests to retrieve all suite statuses\nfunc SuiteStatuses(cfg *config.Config) fiber.Handler {\n\treturn func(c *fiber.Ctx) error {\n\t\tpage, pageSize := extractPageAndPageSizeFromRequest(c, 100)\n\t\tparams := paging.NewSuiteStatusParams().WithPagination(page, pageSize)\n\t\tsuiteStatuses, err := store.Get().GetAllSuiteStatuses(params)\n\t\tif err != nil {\n\t\t\treturn c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{\n\t\t\t\t\"error\": fmt.Sprintf(\"Failed to retrieve suite statuses: %v\", err),\n\t\t\t})\n\t\t}\n\t\t// If no statuses exist yet, create empty ones from config\n\t\tif len(suiteStatuses) == 0 {\n\t\t\tfor _, s := range cfg.Suites {\n\t\t\t\tif s.IsEnabled() {\n\t\t\t\t\tsuiteStatuses = append(suiteStatuses, suite.NewStatus(s))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn c.Status(fiber.StatusOK).JSON(suiteStatuses)\n\t}\n}\n\n// SuiteStatus handles requests to retrieve a single suite's status\nfunc SuiteStatus(cfg *config.Config) fiber.Handler {\n\treturn func(c *fiber.Ctx) error {\n\t\tpage, pageSize := extractPageAndPageSizeFromRequest(c, 100)\n\t\tkey := c.Params(\"key\")\n\t\tparams := paging.NewSuiteStatusParams().WithPagination(page, pageSize)\n\t\tstatus, err := store.Get().GetSuiteStatusByKey(key, params)\n\t\tif err != nil || status == nil {\n\t\t\t// Try to find the suite in config\n\t\t\tfor _, s := range cfg.Suites {\n\t\t\t\tif s.Key() == key {\n\t\t\t\t\tstatus = suite.NewStatus(s)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif status == nil {\n\t\t\t\treturn c.Status(404).JSON(fiber.Map{\n\t\t\t\t\t\"error\": fmt.Sprintf(\"Suite with key '%s' not found\", key),\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\treturn c.Status(fiber.StatusOK).JSON(status)\n\t}\n}\n"
  },
  {
    "path": "api/suite_status_test.go",
    "content": "package api\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/watchdog\"\n)\n\nvar (\n\tsuiteTimestamp = time.Now()\n\n\ttestSuiteEndpoint1 = endpoint.Endpoint{\n\t\tName:                    \"endpoint1\",\n\t\tGroup:                   \"suite-group\",\n\t\tURL:                     \"https://example.org/endpoint1\",\n\t\tMethod:                  \"GET\",\n\t\tInterval:                30 * time.Second,\n\t\tConditions:              []endpoint.Condition{endpoint.Condition(\"[STATUS] == 200\"), endpoint.Condition(\"[RESPONSE_TIME] < 500\")},\n\t\tNumberOfFailuresInARow:  0,\n\t\tNumberOfSuccessesInARow: 0,\n\t}\n\ttestSuiteEndpoint2 = endpoint.Endpoint{\n\t\tName:                    \"endpoint2\",\n\t\tGroup:                   \"suite-group\",\n\t\tURL:                     \"https://example.org/endpoint2\",\n\t\tMethod:                  \"GET\",\n\t\tInterval:                30 * time.Second,\n\t\tConditions:              []endpoint.Condition{endpoint.Condition(\"[STATUS] == 200\"), endpoint.Condition(\"[RESPONSE_TIME] < 300\")},\n\t\tNumberOfFailuresInARow:  0,\n\t\tNumberOfSuccessesInARow: 0,\n\t}\n\ttestSuite = suite.Suite{\n\t\tName:     \"test-suite\",\n\t\tGroup:    \"suite-group\",\n\t\tInterval: 60 * time.Second,\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t&testSuiteEndpoint1,\n\t\t\t&testSuiteEndpoint2,\n\t\t},\n\t}\n\ttestSuccessfulSuiteResult = suite.Result{\n\t\tName:      \"test-suite\",\n\t\tGroup:     \"suite-group\",\n\t\tSuccess:   true,\n\t\tTimestamp: suiteTimestamp,\n\t\tDuration:  250 * time.Millisecond,\n\t\tEndpointResults: []*endpoint.Result{\n\t\t\t{\n\t\t\t\tHostname:   \"example.org\",\n\t\t\t\tIP:         \"127.0.0.1\",\n\t\t\t\tHTTPStatus: 200,\n\t\t\t\tSuccess:    true,\n\t\t\t\tTimestamp:  suiteTimestamp,\n\t\t\t\tDuration:   100 * time.Millisecond,\n\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\t\t\tSuccess:   true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\t\t\tSuccess:   true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tHostname:   \"example.org\",\n\t\t\t\tIP:         \"127.0.0.1\",\n\t\t\t\tHTTPStatus: 200,\n\t\t\t\tSuccess:    true,\n\t\t\t\tTimestamp:  suiteTimestamp,\n\t\t\t\tDuration:   150 * time.Millisecond,\n\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\t\t\tSuccess:   true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[RESPONSE_TIME] < 300\",\n\t\t\t\t\t\tSuccess:   true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\ttestUnsuccessfulSuiteResult = suite.Result{\n\t\tName:      \"test-suite\",\n\t\tGroup:     \"suite-group\",\n\t\tSuccess:   false,\n\t\tTimestamp: suiteTimestamp,\n\t\tDuration:  850 * time.Millisecond,\n\t\tErrors:    []string{\"suite-error-1\", \"suite-error-2\"},\n\t\tEndpointResults: []*endpoint.Result{\n\t\t\t{\n\t\t\t\tHostname:   \"example.org\",\n\t\t\t\tIP:         \"127.0.0.1\",\n\t\t\t\tHTTPStatus: 200,\n\t\t\t\tSuccess:    true,\n\t\t\t\tTimestamp:  suiteTimestamp,\n\t\t\t\tDuration:   100 * time.Millisecond,\n\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\t\t\tSuccess:   true,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\t\t\tSuccess:   true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tHostname:   \"example.org\",\n\t\t\t\tIP:         \"127.0.0.1\",\n\t\t\t\tHTTPStatus: 500,\n\t\t\t\tErrors:     []string{\"endpoint-error-1\"},\n\t\t\t\tSuccess:    false,\n\t\t\t\tTimestamp:  suiteTimestamp,\n\t\t\t\tDuration:   750 * time.Millisecond,\n\t\t\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\t\t\tSuccess:   false,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tCondition: \"[RESPONSE_TIME] < 300\",\n\t\t\t\t\t\tSuccess:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n)\n\nfunc TestSuiteStatus(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tcfg := &config.Config{\n\t\tMetrics: true,\n\t\tSuites: []*suite.Suite{\n\t\t\t{\n\t\t\t\tName:  \"frontend-suite\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:  \"backend-suite\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t},\n\t\tStorage: &storage.Config{\n\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t},\n\t}\n\twatchdog.UpdateSuiteStatus(cfg.Suites[0], &suite.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now(), Name: cfg.Suites[0].Name, Group: cfg.Suites[0].Group})\n\twatchdog.UpdateSuiteStatus(cfg.Suites[1], &suite.Result{Success: false, Duration: time.Second, Timestamp: time.Now(), Name: cfg.Suites[1].Name, Group: cfg.Suites[1].Group})\n\tapi := New(cfg)\n\trouter := api.Router()\n\ttype Scenario struct {\n\t\tName         string\n\t\tPath         string\n\t\tExpectedCode int\n\t\tGzip         bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:         \"suite-status\",\n\t\t\tPath:         \"/api/v1/suites/core_frontend-suite/statuses\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"suite-status-gzip\",\n\t\t\tPath:         \"/api/v1/suites/core_frontend-suite/statuses\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tGzip:         true,\n\t\t},\n\t\t{\n\t\t\tName:         \"suite-status-pagination\",\n\t\t\tPath:         \"/api/v1/suites/core_frontend-suite/statuses?page=1&pageSize=20\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t},\n\t\t{\n\t\t\tName:         \"suite-status-for-invalid-key\",\n\t\t\tPath:         \"/api/v1/suites/invalid_key/statuses\",\n\t\t\tExpectedCode: http.StatusNotFound,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tif scenario.Gzip {\n\t\t\t\trequest.Header.Set(\"Accept-Encoding\", \"gzip\")\n\t\t\t}\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSuiteStatus_SuiteNotInStoreButInConfig(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\ttests := []struct {\n\t\tname         string\n\t\tsuiteKey     string\n\t\tcfg          *config.Config\n\t\texpectedCode int\n\t\texpectJSON   bool\n\t\texpectError  string\n\t}{\n\t\t{\n\t\t\tname:     \"suite-not-in-store-but-exists-in-config-enabled\",\n\t\t\tsuiteKey: \"test-group_test-suite\",\n\t\t\tcfg: &config.Config{\n\t\t\t\tMetrics: true,\n\t\t\t\tSuites: []*suite.Suite{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"test-suite\",\n\t\t\t\t\t\tGroup:   \"test-group\",\n\t\t\t\t\t\tEnabled: boolPtr(true),\n\t\t\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tName:  \"endpoint-1\",\n\t\t\t\t\t\t\t\tGroup: \"test-group\",\n\t\t\t\t\t\t\t\tURL:   \"https://example.com\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStorage: &storage.Config{\n\t\t\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCode: http.StatusOK,\n\t\t\texpectJSON:   true,\n\t\t},\n\t\t{\n\t\t\tname:     \"suite-not-in-store-but-exists-in-config-disabled\",\n\t\t\tsuiteKey: \"test-group_disabled-suite\",\n\t\t\tcfg: &config.Config{\n\t\t\t\tMetrics: true,\n\t\t\t\tSuites: []*suite.Suite{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"disabled-suite\",\n\t\t\t\t\t\tGroup:   \"test-group\",\n\t\t\t\t\t\tEnabled: boolPtr(false),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStorage: &storage.Config{\n\t\t\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCode: http.StatusOK,\n\t\t\texpectJSON:   true,\n\t\t},\n\t\t{\n\t\t\tname:     \"suite-not-in-store-and-not-in-config\",\n\t\t\tsuiteKey: \"nonexistent_suite\",\n\t\t\tcfg: &config.Config{\n\t\t\t\tMetrics: true,\n\t\t\t\tSuites: []*suite.Suite{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"different-suite\",\n\t\t\t\t\t\tGroup: \"different-group\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStorage: &storage.Config{\n\t\t\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCode: http.StatusNotFound,\n\t\t\texpectError:  \"Suite with key 'nonexistent_suite' not found\",\n\t\t},\n\t\t{\n\t\t\tname:     \"suite-with-empty-group-in-config\",\n\t\t\tsuiteKey: \"_empty-group-suite\",\n\t\t\tcfg: &config.Config{\n\t\t\t\tMetrics: true,\n\t\t\t\tSuites: []*suite.Suite{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"empty-group-suite\",\n\t\t\t\t\t\tGroup: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStorage: &storage.Config{\n\t\t\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCode: http.StatusOK,\n\t\t\texpectJSON:   true,\n\t\t},\n\t\t{\n\t\t\tname:     \"suite-nil-enabled-defaults-to-true\",\n\t\t\tsuiteKey: \"default_enabled-suite\",\n\t\t\tcfg: &config.Config{\n\t\t\t\tMetrics: true,\n\t\t\t\tSuites: []*suite.Suite{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"enabled-suite\",\n\t\t\t\t\t\tGroup:   \"default\",\n\t\t\t\t\t\tEnabled: nil,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStorage: &storage.Config{\n\t\t\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCode: http.StatusOK,\n\t\t\texpectJSON:   true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tapi := New(tt.cfg)\n\t\t\trouter := api.Router()\n\t\t\trequest := httptest.NewRequest(\"GET\", \"/api/v1/suites/\"+tt.suiteKey+\"/statuses\", http.NoBody)\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Router test failed: %v\", err)\n\t\t\t}\n\t\t\tdefer response.Body.Close()\n\t\t\tif response.StatusCode != tt.expectedCode {\n\t\t\t\tt.Errorf(\"Expected status code %d, got %d\", tt.expectedCode, response.StatusCode)\n\t\t\t}\n\t\t\tbody, err := io.ReadAll(response.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to read response body: %v\", err)\n\t\t\t}\n\t\t\tbodyStr := string(body)\n\t\t\tif tt.expectJSON {\n\t\t\t\tif response.Header.Get(\"Content-Type\") != \"application/json\" {\n\t\t\t\t\tt.Errorf(\"Expected JSON content type, got %s\", response.Header.Get(\"Content-Type\"))\n\t\t\t\t}\n\t\t\t\tif len(bodyStr) == 0 || bodyStr[0] != '{' {\n\t\t\t\t\tt.Errorf(\"Expected JSON response, got: %s\", bodyStr)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif tt.expectError != \"\" {\n\t\t\t\tif !contains(bodyStr, tt.expectError) {\n\t\t\t\t\tt.Errorf(\"Expected error message '%s' in response, got: %s\", tt.expectError, bodyStr)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSuiteStatuses(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tfirstResult := &testSuccessfulSuiteResult\n\tsecondResult := &testUnsuccessfulSuiteResult\n\tstore.Get().InsertSuiteResult(&testSuite, firstResult)\n\tstore.Get().InsertSuiteResult(&testSuite, secondResult)\n\t// Can't be bothered dealing with timezone issues on the worker that runs the automated tests\n\tfirstResult.Timestamp = time.Time{}\n\tsecondResult.Timestamp = time.Time{}\n\tfor i := range firstResult.EndpointResults {\n\t\tfirstResult.EndpointResults[i].Timestamp = time.Time{}\n\t}\n\tfor i := range secondResult.EndpointResults {\n\t\tsecondResult.EndpointResults[i].Timestamp = time.Time{}\n\t}\n\tapi := New(&config.Config{\n\t\tMetrics: true,\n\t\tStorage: &storage.Config{\n\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t},\n\t})\n\trouter := api.Router()\n\ttype Scenario struct {\n\t\tName         string\n\t\tPath         string\n\t\tExpectedCode int\n\t\tExpectedBody string\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:         \"no-pagination\",\n\t\t\tPath:         \"/api/v1/suites/statuses\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"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\"]}]}]`,\n\t\t},\n\t\t{\n\t\t\tName:         \"pagination-first-result\",\n\t\t\tPath:         \"/api/v1/suites/statuses?page=1&pageSize=1\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"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\"]}]}]`,\n\t\t},\n\t\t{\n\t\t\tName:         \"pagination-second-result\",\n\t\t\tPath:         \"/api/v1/suites/statuses?page=2&pageSize=1\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"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\"}]}]}]`,\n\t\t},\n\t\t{\n\t\t\tName:         \"pagination-no-results\",\n\t\t\tPath:         \"/api/v1/suites/statuses?page=5&pageSize=20\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"name\":\"test-suite\",\"group\":\"suite-group\",\"key\":\"suite-group_test-suite\",\"results\":[]}]`,\n\t\t},\n\t\t{\n\t\t\tName:         \"invalid-pagination-should-fall-back-to-default\",\n\t\t\tPath:         \"/api/v1/suites/statuses?page=INVALID&pageSize=INVALID\",\n\t\t\tExpectedCode: http.StatusOK,\n\t\t\tExpectedBody: `[{\"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\"]}]}]`,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\trequest := httptest.NewRequest(\"GET\", scenario.Path, http.NoBody)\n\t\t\tresponse, err := router.Test(request)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer response.Body.Close()\n\t\t\tif response.StatusCode != scenario.ExpectedCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)\n\t\t\t}\n\t\t\tbody, err := io.ReadAll(response.Body)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"expected err to be nil, but was\", err)\n\t\t\t}\n\t\t\tif string(body) != scenario.ExpectedBody {\n\t\t\t\tt.Errorf(\"expected:\\n %s\\n\\ngot:\\n %s\", scenario.ExpectedBody, string(body))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSuiteStatuses_NoSuitesInStoreButExistInConfig(t *testing.T) {\n\tdefer store.Get().Clear()\n\tdefer cache.Clear()\n\tcfg := &config.Config{\n\t\tMetrics: true,\n\t\tSuites: []*suite.Suite{\n\t\t\t{\n\t\t\t\tName:    \"config-only-suite-1\",\n\t\t\t\tGroup:   \"test-group\",\n\t\t\t\tEnabled: boolPtr(true),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:    \"config-only-suite-2\",\n\t\t\t\tGroup:   \"test-group\",\n\t\t\t\tEnabled: boolPtr(true),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:    \"disabled-suite\",\n\t\t\t\tGroup:   \"test-group\",\n\t\t\t\tEnabled: boolPtr(false),\n\t\t\t},\n\t\t},\n\t\tStorage: &storage.Config{\n\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t},\n\t}\n\tapi := New(cfg)\n\trouter := api.Router()\n\trequest := httptest.NewRequest(\"GET\", \"/api/v1/suites/statuses\", http.NoBody)\n\tresponse, err := router.Test(request)\n\tif err != nil {\n\t\tt.Fatalf(\"Router test failed: %v\", err)\n\t}\n\tdefer response.Body.Close()\n\tif response.StatusCode != http.StatusOK {\n\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, response.StatusCode)\n\t}\n\tbody, err := io.ReadAll(response.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read response body: %v\", err)\n\t}\n\tbodyStr := string(body)\n\tif !contains(bodyStr, \"config-only-suite-1\") {\n\t\tt.Error(\"Expected config-only-suite-1 in response\")\n\t}\n\tif !contains(bodyStr, \"config-only-suite-2\") {\n\t\tt.Error(\"Expected config-only-suite-2 in response\")\n\t}\n\tif contains(bodyStr, \"disabled-suite\") {\n\t\tt.Error(\"Should not include disabled-suite in response\")\n\t}\n}\n\nfunc boolPtr(b bool) *bool {\n\treturn &b\n}\n\nfunc contains(s, substr string) bool {\n\treturn len(s) >= len(substr) && (s == substr || len(substr) == 0 ||\n\t\t(len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||\n\t\t\tfunc() bool {\n\t\t\t\tfor i := 1; i <= len(s)-len(substr); i++ {\n\t\t\t\t\tif s[i:i+len(substr)] == substr {\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn false\n\t\t\t}())))\n}\n"
  },
  {
    "path": "api/util.go",
    "content": "package api\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/gofiber/fiber/v2\"\n)\n\nconst (\n\t// DefaultPage is the default page to use if none is specified or an invalid value is provided\n\tDefaultPage = 1\n\n\t// DefaultPageSize is the default page size to use if none is specified or an invalid value is provided\n\tDefaultPageSize = 50\n)\n\nfunc extractPageAndPageSizeFromRequest(c *fiber.Ctx, maximumNumberOfResults int) (page, pageSize int) {\n\tvar err error\n\tif pageParameter := c.Query(\"page\"); len(pageParameter) == 0 {\n\t\tpage = DefaultPage\n\t} else {\n\t\tpage, err = strconv.Atoi(pageParameter)\n\t\tif err != nil {\n\t\t\tpage = DefaultPage\n\t\t}\n\t\tif page < 1 {\n\t\t\tpage = DefaultPage\n\t\t}\n\t}\n\tif pageSizeParameter := c.Query(\"pageSize\"); len(pageSizeParameter) == 0 {\n\t\tpageSize = DefaultPageSize\n\t} else {\n\t\tpageSize, err = strconv.Atoi(pageSizeParameter)\n\t\tif err != nil {\n\t\t\tpageSize = DefaultPageSize\n\t\t}\n\t}\n\tif page == 1 && pageSize > maximumNumberOfResults {\n\t\t// If the page is 1 and the page size is greater than the maximum number of results, return\n\t\t// no more than the maximum number of results\n\t\tpageSize = maximumNumberOfResults\n\t} else if pageSize < 1 {\n\t\tpageSize = DefaultPageSize\n\t}\n\treturn\n}\n"
  },
  {
    "path": "api/util_test.go",
    "content": "package api\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/gofiber/fiber/v2\"\n\t\"github.com/valyala/fasthttp\"\n)\n\nfunc TestExtractPageAndPageSizeFromRequest(t *testing.T) {\n\ttype Scenario struct {\n\t\tName                   string\n\t\tPage                   string\n\t\tPageSize               string\n\t\tExpectedPage           int\n\t\tExpectedPageSize       int\n\t\tMaximumNumberOfResults int\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tPage:                   \"1\",\n\t\t\tPageSize:               \"20\",\n\t\t\tExpectedPage:           1,\n\t\t\tExpectedPageSize:       20,\n\t\t\tMaximumNumberOfResults: 20,\n\t\t},\n\t\t{\n\t\t\tPage:                   \"2\",\n\t\t\tPageSize:               \"10\",\n\t\t\tExpectedPage:           2,\n\t\t\tExpectedPageSize:       10,\n\t\t\tMaximumNumberOfResults: 40,\n\t\t},\n\t\t{\n\t\t\tPage:                   \"2\",\n\t\t\tPageSize:               \"10\",\n\t\t\tExpectedPage:           2,\n\t\t\tExpectedPageSize:       10,\n\t\t\tMaximumNumberOfResults: 200,\n\t\t},\n\t\t{\n\t\t\tPage:                   \"1\",\n\t\t\tPageSize:               \"999999\",\n\t\t\tExpectedPage:           1,\n\t\t\tExpectedPageSize:       storage.DefaultMaximumNumberOfResults,\n\t\t\tMaximumNumberOfResults: 100,\n\t\t},\n\t\t{\n\t\t\tPage:                   \"-1\",\n\t\t\tPageSize:               \"-1\",\n\t\t\tExpectedPage:           DefaultPage,\n\t\t\tExpectedPageSize:       DefaultPageSize,\n\t\t\tMaximumNumberOfResults: 20,\n\t\t},\n\t\t{\n\t\t\tPage:                   \"invalid\",\n\t\t\tPageSize:               \"invalid\",\n\t\t\tExpectedPage:           DefaultPage,\n\t\t\tExpectedPageSize:       DefaultPageSize,\n\t\t\tMaximumNumberOfResults: 100,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(\"page-\"+scenario.Page+\"-pageSize-\"+scenario.PageSize, func(t *testing.T) {\n\t\t\t//request := httptest.NewRequest(\"GET\", fmt.Sprintf(\"/api/v1/statuses?page=%s&pageSize=%s\", scenario.Page, scenario.PageSize), http.NoBody)\n\t\t\tapp := fiber.New()\n\t\t\tc := app.AcquireCtx(&fasthttp.RequestCtx{})\n\t\t\tdefer app.ReleaseCtx(c)\n\t\t\tc.Request().SetRequestURI(fmt.Sprintf(\"/api/v1/statuses?page=%s&pageSize=%s\", scenario.Page, scenario.PageSize))\n\t\t\tactualPage, actualPageSize := extractPageAndPageSizeFromRequest(c, scenario.MaximumNumberOfResults)\n\t\t\tif actualPage != scenario.ExpectedPage {\n\t\t\t\tt.Errorf(\"expected %d, got %d\", scenario.ExpectedPage, actualPage)\n\t\t\t}\n\t\t\tif actualPageSize != scenario.ExpectedPageSize {\n\t\t\t\tt.Errorf(\"expected %d, got %d\", scenario.ExpectedPageSize, actualPageSize)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "client/client.go",
    "content": "package client\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/smtp\"\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/gocache/v2\"\n\t\"github.com/TwiN/logr\"\n\t\"github.com/TwiN/whois\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/ishidawataru/sctp\"\n\t\"github.com/miekg/dns\"\n\tping \"github.com/prometheus-community/pro-bing\"\n\t\"github.com/registrobr/rdap\"\n\t\"github.com/registrobr/rdap/protocol\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\nconst (\n\tdnsPort = 53\n)\n\nvar (\n\t// injectedHTTPClient is used for testing purposes\n\tinjectedHTTPClient *http.Client\n\n\twhoisClient              = whois.NewClient().WithReferralCache(true)\n\twhoisExpirationDateCache = gocache.NewCache().WithMaxSize(10000).WithDefaultTTL(24 * time.Hour)\n\trdapClient               = rdap.NewClient(nil)\n)\n\n// GetHTTPClient returns the shared HTTP client, or the client from the configuration passed\nfunc GetHTTPClient(config *Config) *http.Client {\n\tif injectedHTTPClient != nil {\n\t\treturn injectedHTTPClient\n\t}\n\tif config == nil {\n\t\treturn defaultConfig.getHTTPClient()\n\t}\n\treturn config.getHTTPClient()\n}\n\n// GetDomainExpiration retrieves the duration until the domain provided expires\nfunc GetDomainExpiration(hostname string) (domainExpiration time.Duration, err error) {\n\tvar retrievedCachedValue bool\n\tif v, exists := whoisExpirationDateCache.Get(hostname); exists {\n\t\tdomainExpiration = time.Until(v.(time.Time))\n\t\tretrievedCachedValue = true\n\t\t// If the domain OR the TTL is not going to expire in less than 24 hours\n\t\t// we don't have to refresh the cache. Otherwise, we'll refresh it.\n\t\tcacheEntryTTL, _ := whoisExpirationDateCache.TTL(hostname)\n\t\tif cacheEntryTTL > 24*time.Hour && domainExpiration > 24*time.Hour {\n\t\t\t// No need to refresh, so we'll just return the cached values\n\t\t\treturn domainExpiration, nil\n\t\t}\n\t}\n\twhoisResponse, err := rdapQuery(hostname)\n\tif err != nil {\n\t\t// fallback to WHOIS protocol\n\t\twhoisResponse, err = whoisClient.QueryAndParse(hostname)\n\t}\n\tif err != nil {\n\t\tif !retrievedCachedValue { // Add an error unless we already retrieved a cached value\n\t\t\treturn 0, fmt.Errorf(\"error querying and parsing hostname using whois client: %w\", err)\n\t\t}\n\t} else {\n\t\tdomainExpiration = time.Until(whoisResponse.ExpirationDate)\n\t\tif domainExpiration > 720*time.Hour {\n\t\t\twhoisExpirationDateCache.SetWithTTL(hostname, whoisResponse.ExpirationDate, 240*time.Hour)\n\t\t} else {\n\t\t\twhoisExpirationDateCache.SetWithTTL(hostname, whoisResponse.ExpirationDate, 72*time.Hour)\n\t\t}\n\t}\n\treturn domainExpiration, nil\n}\n\n// parseLocalAddressPlaceholder returns a string with the local address replaced\nfunc parseLocalAddressPlaceholder(item string, localAddr net.Addr) string {\n\titem = strings.ReplaceAll(item, \"[LOCAL_ADDRESS]\", localAddr.String())\n\treturn item\n}\n\n// CanCreateNetworkConnection checks whether a connection can be established with a TCP or UDP endpoint\nfunc CanCreateNetworkConnection(netType string, address string, body string, config *Config) (bool, []byte) {\n\tconst (\n\t\tMaximumMessageSize = 1024 // in bytes\n\t)\n\tconnection, err := net.DialTimeout(netType, address, config.Timeout)\n\tif err != nil {\n\t\treturn false, nil\n\t}\n\tdefer connection.Close()\n\tif body != \"\" {\n\t\tbody = parseLocalAddressPlaceholder(body, connection.LocalAddr())\n\t\tconnection.SetDeadline(time.Now().Add(config.Timeout))\n\t\t_, err = connection.Write([]byte(body))\n\t\tif err != nil {\n\t\t\treturn false, nil\n\t\t}\n\t\tbuf := make([]byte, MaximumMessageSize)\n\t\tn, err := connection.Read(buf)\n\t\tif err != nil {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn true, buf[:n]\n\t}\n\treturn true, nil\n}\n\n// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint\nfunc CanCreateSCTPConnection(address string, config *Config) bool {\n\tch := make(chan bool, 1)\n\tgo (func(res chan bool) {\n\t\taddr, err := sctp.ResolveSCTPAddr(\"sctp\", address)\n\t\tif err != nil {\n\t\t\tres <- false\n\t\t\treturn\n\t\t}\n\n\t\tconn, err := sctp.DialSCTP(\"sctp\", nil, addr)\n\t\tif err != nil {\n\t\t\tres <- false\n\t\t\treturn\n\t\t}\n\t\t_ = conn.Close()\n\t\tres <- true\n\t})(ch)\n\tselect {\n\tcase result := <-ch:\n\t\treturn result\n\tcase <-time.After(config.Timeout):\n\t\treturn false\n\t}\n}\n\n// CanPerformStartTLS checks whether a connection can be established to an address using the STARTTLS protocol\nfunc CanPerformStartTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {\n\thostAndPort := strings.Split(address, \":\")\n\tif len(hostAndPort) != 2 {\n\t\treturn false, nil, errors.New(\"invalid address for starttls, format must be host:port\")\n\t}\n\n\tvar connection net.Conn\n\tvar dnsResolver *DNSResolverConfig\n\n\tif config.HasCustomDNSResolver() {\n\t\tdnsResolver, err = config.parseDNSResolver()\n\n\t\tif err != nil {\n\t\t\t// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.\n\t\t\t// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)\n\t\t\tlogr.Errorf(\"[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error: %s\", err.Error())\n\t\t} else {\n\t\t\tdialer := &net.Dialer{\n\t\t\t\tResolver: &net.Resolver{\n\t\t\t\t\tPreferGo: true,\n\t\t\t\t\tDial: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\t\t\t\t\td := net.Dialer{}\n\t\t\t\t\t\treturn d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+\":\"+dnsResolver.Port)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tconnection, err = dialer.DialContext(context.Background(), \"tcp\", address)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t} else {\n\t\tconnection, err = net.DialTimeout(\"tcp\", address, config.Timeout)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\n\tsmtpClient, err := smtp.NewClient(connection, hostAndPort[0])\n\tif err != nil {\n\t\treturn\n\t}\n\terr = smtpClient.StartTLS(&tls.Config{\n\t\tInsecureSkipVerify: config.Insecure,\n\t\tServerName:         hostAndPort[0],\n\t})\n\tif err != nil {\n\t\treturn\n\t}\n\tif state, ok := smtpClient.TLSConnectionState(); ok {\n\t\tcertificate = state.PeerCertificates[0]\n\t} else {\n\t\treturn false, nil, errors.New(\"could not get TLS connection state\")\n\t}\n\treturn true, certificate, nil\n}\n\n// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol\nfunc CanPerformTLS(address string, body string, config *Config) (connected bool, response []byte, certificate *x509.Certificate, err error) {\n\tconst (\n\t\tMaximumMessageSize = 1024 // in bytes\n\t)\n\tconnection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, \"tcp\", address, &tls.Config{\n\t\tInsecureSkipVerify: config.Insecure,\n\t})\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer connection.Close()\n\tverifiedChains := connection.ConnectionState().VerifiedChains\n\t// If config.Insecure is set to true, verifiedChains will be an empty list []\n\t// We should get the parsed certificates from PeerCertificates, it can't be empty on the client side\n\t// Reference: https://pkg.go.dev/crypto/tls#PeerCertificates\n\tif len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {\n\t\tpeerCertificates := connection.ConnectionState().PeerCertificates\n\t\tcertificate = peerCertificates[0]\n\t} else {\n\t\tcertificate = verifiedChains[0][0]\n\t}\n\tconnected = true\n\tif body != \"\" {\n\t\tbody = parseLocalAddressPlaceholder(body, connection.LocalAddr())\n\t\tconnection.SetDeadline(time.Now().Add(config.Timeout))\n\t\t_, err = connection.Write([]byte(body))\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tbuf := make([]byte, MaximumMessageSize)\n\t\tvar n int\n\t\tn, err = connection.Read(buf)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tresponse = buf[:n]\n\t}\n\treturn\n}\n\n// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address\n// using the SSH protocol.\nfunc CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {\n\tvar port string\n\tif strings.Contains(address, \":\") {\n\t\taddressAndPort := strings.Split(address, \":\")\n\t\tif len(addressAndPort) != 2 {\n\t\t\treturn false, nil, errors.New(\"invalid address for ssh, format must be host:port\")\n\t\t}\n\t\taddress = addressAndPort[0]\n\t\tport = addressAndPort[1]\n\t} else {\n\t\tport = \"22\"\n\t}\n\n\t// Build auth methods: prefer parsed private key if provided, fall back to password.\n\tvar authMethods []ssh.AuthMethod\n\tif len(privateKey) > 0 {\n\t\tif signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {\n\t\t\tauthMethods = append(authMethods, ssh.PublicKeys(signer))\n\t\t} else {\n\t\t\treturn false, nil, fmt.Errorf(\"invalid private key: %w\", err)\n\t\t}\n\t}\n\tif len(password) > 0 {\n\t\tauthMethods = append(authMethods, ssh.Password(password))\n\t}\n\n\tcli, err := ssh.Dial(\"tcp\", net.JoinHostPort(address, port), &ssh.ClientConfig{\n\t\tHostKeyCallback: ssh.InsecureIgnoreHostKey(),\n\t\tUser:            username,\n\t\tAuth:            authMethods,\n\t\tTimeout:         config.Timeout,\n\t})\n\tif err != nil {\n\t\treturn false, nil, err\n\t}\n\treturn true, cli, nil\n}\n\nfunc CheckSSHBanner(address string, cfg *Config) (bool, int, error) {\n\tvar port string\n\tif strings.Contains(address, \":\") {\n\t\taddressAndPort := strings.Split(address, \":\")\n\t\tif len(addressAndPort) != 2 {\n\t\t\treturn false, 1, errors.New(\"invalid address for ssh, format must be ssh://host:port\")\n\t\t}\n\t\taddress = addressAndPort[0]\n\t\tport = addressAndPort[1]\n\t} else {\n\t\tport = \"22\"\n\t}\n\tdialer := net.Dialer{}\n\tconnStr := net.JoinHostPort(address, port)\n\tconn, err := dialer.Dial(\"tcp\", connStr)\n\tif err != nil {\n\t\treturn false, 1, err\n\t}\n\tdefer conn.Close()\n\tconn.SetReadDeadline(time.Now().Add(time.Second))\n\tbuf := make([]byte, 256)\n\t_, err = io.ReadAtLeast(conn, buf, 1)\n\tif err != nil {\n\t\treturn false, 1, err\n\t}\n\treturn true, 0, err\n}\n\n// ExecuteSSHCommand executes a command to an address using the SSH protocol.\nfunc ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, []byte, error) {\n\ttype Body struct {\n\t\tCommand string `json:\"command\"`\n\t}\n\tdefer sshClient.Close()\n\tvar b Body\n\tbody = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())\n\tif err := json.Unmarshal([]byte(body), &b); err != nil {\n\t\treturn false, 0, nil, err\n\t}\n\tsess, err := sshClient.NewSession()\n\tif err != nil {\n\t\treturn false, 0, nil, err\n\t}\n\t// Capture stdout\n\tvar stdout bytes.Buffer\n\tsess.Stdout = &stdout\n\terr = sess.Start(b.Command)\n\tif err != nil {\n\t\treturn false, 0, nil, err\n\t}\n\tdefer sess.Close()\n\terr = sess.Wait()\n\toutput := stdout.Bytes()\n\tif err == nil {\n\t\treturn true, 0, output, nil\n\t}\n\tvar exitErr *ssh.ExitError\n\tif ok := errors.As(err, &exitErr); !ok {\n\t\treturn false, 0, nil, err\n\t}\n\treturn true, exitErr.ExitStatus(), output, nil\n}\n\n// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged\n//\n// Note that this function takes at least 100ms, even if the address is 127.0.0.1\nfunc Ping(address string, config *Config) (bool, time.Duration) {\n\tpinger := ping.New(address)\n\tpinger.Count = 1\n\tpinger.Timeout = config.Timeout\n\tpinger.SetPrivileged(ShouldRunPingerAsPrivileged())\n\tpinger.SetNetwork(config.Network)\n\terr := pinger.Run()\n\tif err != nil {\n\t\treturn false, 0\n\t}\n\tif pinger.Statistics() != nil {\n\t\t// If the packet loss is 100, it means that the packet didn't reach the host\n\t\tif pinger.Statistics().PacketLoss == 100 {\n\t\t\treturn false, pinger.Timeout\n\t\t}\n\t\treturn true, pinger.Statistics().MaxRtt\n\t}\n\treturn true, 0\n}\n\n// ShouldRunPingerAsPrivileged will determine whether or not to run pinger in privileged mode.\n// 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\nfunc ShouldRunPingerAsPrivileged() bool {\n\t// Set the pinger's privileged mode to false for darwin\n\t// See https://github.com/TwiN/gatus/issues/132\n\t// linux should also be set to false, but there are potential complications\n\t// See https://github.com/TwiN/gatus/pull/748 and https://github.com/TwiN/gatus/issues/697#issuecomment-2081700989\n\t//\n\t// Note that for this to work on Linux, Gatus must run with sudo privileges. (in certain cases)\n\t// See https://github.com/prometheus-community/pro-bing#linux\n\tif runtime.GOOS == \"windows\" {\n\t\treturn true\n\t}\n\t// To actually check for cap_net_raw capabilities, we would need to add \"kernel.org/pub/linux/libs/security/libcap/cap\" to gatus.\n\t// Or use a syscall and check for permission errors, but this requires platform specific compilation\n\t// As a backstop we can simply check the effective user id and run as privileged when running as root\n\treturn os.Geteuid() == 0\n}\n\n// QueryWebSocket opens a websocket connection, write `body` and return a message from the server\nfunc QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {\n\tconst (\n\t\tOrigin = \"http://localhost/\"\n\t)\n\tvar (\n\t\tdialer = websocket.Dialer{\n\t\t\tEnableCompression: true,\n\t\t}\n\t\twsHeaders = make(http.Header)\n\t)\n\n\twsHeaders.Set(\"Origin\", Origin)\n\tfor name, value := range headers {\n\t\twsHeaders.Set(name, value)\n\t}\n\n\tctx := context.Background()\n\tif config != nil {\n\t\tif config.Timeout > 0 {\n\t\t\tvar cancel context.CancelFunc\n\t\t\tctx, cancel = context.WithTimeout(ctx, config.Timeout)\n\t\t\tdefer cancel()\n\t\t}\n\t\tdialer.TLSClientConfig = &tls.Config{\n\t\t\tInsecureSkipVerify: config.Insecure,\n\t\t}\n\t\tif config.HasTLSConfig() && config.TLS.isValid() == nil {\n\t\t\tdialer.TLSClientConfig = configureTLS(dialer.TLSClientConfig, *config.TLS)\n\t\t}\n\t}\n\t// Dial URL\n\tws, _, err := dialer.DialContext(ctx, address, wsHeaders)\n\tif err != nil {\n\t\treturn false, nil, fmt.Errorf(\"error dialing websocket: %w\", err)\n\t}\n\tdefer ws.Close()\n\tbody = parseLocalAddressPlaceholder(body, ws.LocalAddr())\n\t// Write message\n\tif err := ws.WriteMessage(websocket.TextMessage, []byte(body)); err != nil {\n\t\treturn false, nil, fmt.Errorf(\"error writing websocket body: %w\", err)\n\t}\n\t// Read message\n\tmsgType, msg, err := ws.ReadMessage()\n\tif err != nil {\n\t\treturn false, nil, fmt.Errorf(\"error reading websocket message: %w\", err)\n\t} else if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {\n\t\treturn false, nil, fmt.Errorf(\"unexpected websocket message type: %d, expected %d or %d\", msgType, websocket.TextMessage, websocket.BinaryMessage)\n\t}\n\treturn true, msg, nil\n}\n\nfunc QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {\n\tif !strings.Contains(url, \":\") {\n\t\turl = fmt.Sprintf(\"%s:%d\", url, dnsPort)\n\t}\n\tqueryTypeAsUint16 := dns.StringToType[queryType]\n\t// Special handling: if this is a PTR query and queryName looks like a plain IP,\n\t// convert it to the proper reverse lookup domain automatically.\n\tif queryTypeAsUint16 == dns.TypePTR &&\n\t\t!strings.HasSuffix(queryName, \".in-addr.arpa.\") &&\n\t\t!strings.HasSuffix(queryName, \".ip6.arpa.\") {\n\t\tif rev, convErr := reverseNameForIP(queryName); convErr == nil {\n\t\t\tqueryName = rev\n\t\t} else {\n\t\t\treturn false, \"\", nil, convErr\n\t\t}\n\t}\n\tc := new(dns.Client)\n\tm := new(dns.Msg)\n\tm.SetQuestion(queryName, queryTypeAsUint16)\n\tr, _, err := c.Exchange(m, url)\n\tif err != nil {\n\t\tlogr.Infof(\"[client.QueryDNS] Error exchanging DNS message: %v\", err)\n\t\treturn false, \"\", nil, err\n\t}\n\tconnected = true\n\tdnsRcode = dns.RcodeToString[r.Rcode]\n\tfor _, rr := range r.Answer {\n\t\tswitch rr.Header().Rrtype {\n\t\tcase dns.TypeA:\n\t\t\tif a, ok := rr.(*dns.A); ok {\n\t\t\t\tbody = []byte(a.A.String())\n\t\t\t}\n\t\tcase dns.TypeAAAA:\n\t\t\tif aaaa, ok := rr.(*dns.AAAA); ok {\n\t\t\t\tbody = []byte(aaaa.AAAA.String())\n\t\t\t}\n\t\tcase dns.TypeCNAME:\n\t\t\tif cname, ok := rr.(*dns.CNAME); ok {\n\t\t\t\tbody = []byte(cname.Target)\n\t\t\t}\n\t\tcase dns.TypeMX:\n\t\t\tif mx, ok := rr.(*dns.MX); ok {\n\t\t\t\tbody = []byte(mx.Mx)\n\t\t\t}\n\t\tcase dns.TypeNS:\n\t\t\tif ns, ok := rr.(*dns.NS); ok {\n\t\t\t\tbody = []byte(ns.Ns)\n\t\t\t}\n\t\tcase dns.TypePTR:\n\t\t\tif ptr, ok := rr.(*dns.PTR); ok {\n\t\t\t\tbody = []byte(ptr.Ptr)\n\t\t\t}\n\t\tcase dns.TypeSRV:\n\t\t\tif srv, ok := rr.(*dns.SRV); ok {\n\t\t\t\tbody = []byte(fmt.Sprintf(\"%s:%d\", srv.Target, srv.Port))\n\t\t\t}\n\t\tdefault:\n\t\t\tbody = []byte(\"query type is not supported yet\")\n\t\t}\n\t}\n\treturn connected, dnsRcode, body, nil\n}\n\n// InjectHTTPClient is used to inject a custom HTTP client for testing purposes\nfunc InjectHTTPClient(httpClient *http.Client) {\n\tinjectedHTTPClient = httpClient\n}\n\n// rdapQuery returns domain expiration via RDAP protocol\nfunc rdapQuery(hostname string) (*whois.Response, error) {\n\tdata, _, err := rdapClient.Query(hostname, nil, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdomain, ok := data.(*protocol.Domain)\n\tif !ok {\n\t\treturn nil, errors.New(\"invalid domain type\")\n\t}\n\tresponse := whois.Response{}\n\tfor _, e := range domain.Events {\n\t\tif e.Action == \"expiration\" {\n\t\t\tresponse.ExpirationDate = e.Date.Time\n\t\t\tbreak\n\t\t}\n\t}\n\treturn &response, nil\n}\n\n// helper to reverse IP and add in-addr.arpa. IPv4 and IPv6\nfunc reverseNameForIP(ipStr string) (string, error) {\n\tip := net.ParseIP(ipStr)\n\tif ip == nil {\n\t\treturn \"\", fmt.Errorf(\"invalid IP: %s\", ipStr)\n\t}\n\n\tif ipv4 := ip.To4(); ipv4 != nil {\n\t\tparts := strings.Split(ipv4.String(), \".\")\n\t\tfor i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {\n\t\t\tparts[i], parts[j] = parts[j], parts[i]\n\t\t}\n\t\treturn strings.Join(parts, \".\") + \".in-addr.arpa.\", nil\n\t}\n\n\tip = ip.To16()\n\thexStr := hex.EncodeToString(ip)\n\tnibbles := strings.Split(hexStr, \"\")\n\tfor i, j := 0, len(nibbles)-1; i < j; i, j = i+1, j-1 {\n\t\tnibbles[i], nibbles[j] = nibbles[j], nibbles[i]\n\t}\n\treturn strings.Join(nibbles, \".\") + \".ip6.arpa.\", nil\n}\n"
  },
  {
    "path": "client/client_test.go",
    "content": "package client\n\nimport (\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"os\"\n\t\"runtime\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint/dns\"\n\t\"github.com/TwiN/gatus/v5/pattern\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestGetHTTPClient(t *testing.T) {\n\tt.Parallel()\n\tcfg := &Config{\n\t\tInsecure:       false,\n\t\tIgnoreRedirect: false,\n\t\tTimeout:        0,\n\t\tDNSResolver:    \"tcp://1.1.1.1:53\",\n\t\tOAuth2Config: &OAuth2Config{\n\t\t\tClientID:     \"00000000-0000-0000-0000-000000000000\",\n\t\t\tClientSecret: \"secretsauce\",\n\t\t\tTokenURL:     \"https://token-server.local/token\",\n\t\t\tScopes:       []string{\"https://application.local/.default\"},\n\t\t},\n\t}\n\terr := cfg.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Errorf(\"expected error to be nil, but got: `%s`\", err)\n\t}\n\tif GetHTTPClient(cfg) == nil {\n\t\tt.Error(\"expected client to not be nil\")\n\t}\n\tif GetHTTPClient(nil) == nil {\n\t\tt.Error(\"expected client to not be nil\")\n\t}\n}\n\nfunc TestRdapQuery(t *testing.T) {\n\tt.Parallel()\n\tif _, err := rdapQuery(\"1.1.1.1\"); err == nil {\n\t\tt.Error(\"expected an error due to the invalid domain type\")\n\t}\n\tif _, err := rdapQuery(\"eurid.eu\"); err == nil {\n\t\tt.Error(\"expected an error as there is no RDAP support currently in .eu\")\n\t}\n\tif response, err := rdapQuery(\"example.com\"); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t} else if response.ExpirationDate.Unix() <= 0 {\n\t\tt.Error(\"expected to have a valid expiry date, got\", response.ExpirationDate.Unix())\n\t}\n}\n\nfunc TestGetDomainExpiration(t *testing.T) {\n\tt.Parallel()\n\tif domainExpiration, err := GetDomainExpiration(\"gatus.io\"); err != nil {\n\t\tt.Fatalf(\"expected error to be nil, but got: `%s`\", err)\n\t} else if domainExpiration <= 0 {\n\t\tt.Error(\"expected domain expiration to be higher than 0\")\n\t}\n\tif domainExpiration, err := GetDomainExpiration(\"gatus.io\"); err != nil {\n\t\tt.Errorf(\"expected error to be nil, but got: `%s`\", err)\n\t} else if domainExpiration <= 0 {\n\t\tt.Error(\"expected domain expiration to be higher than 0\")\n\t}\n\t// Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh\n\twhoisExpirationDateCache.SetWithTTL(\"gatus.io\", time.Now().Add(time.Hour), 25*time.Hour)\n\tif domainExpiration, err := GetDomainExpiration(\"gatus.io\"); err != nil {\n\t\tt.Errorf(\"expected error to be nil, but got: `%s`\", err)\n\t} else if domainExpiration <= 0 {\n\t\tt.Error(\"expected domain expiration to be higher than 0\")\n\t}\n\t// Make sure the refresh works when the ttl is <24 hours\n\twhoisExpirationDateCache.SetWithTTL(\"gatus.io\", time.Now().Add(35*time.Hour), 23*time.Hour)\n\tif domainExpiration, err := GetDomainExpiration(\"gatus.io\"); err != nil {\n\t\tt.Errorf(\"expected error to be nil, but got: `%s`\", err)\n\t} else if domainExpiration <= 0 {\n\t\tt.Error(\"expected domain expiration to be higher than 0\")\n\t}\n}\n\nfunc TestPing(t *testing.T) {\n\tt.Parallel()\n\tif success, rtt := Ping(\"127.0.0.1\", &Config{Timeout: 500 * time.Millisecond}); !success {\n\t\tt.Error(\"expected true\")\n\t\tif rtt == 0 {\n\t\t\tt.Error(\"Round-trip time returned on success should've higher than 0\")\n\t\t}\n\t}\n\tif success, rtt := Ping(\"256.256.256.256\", &Config{Timeout: 500 * time.Millisecond}); success {\n\t\tt.Error(\"expected false, because the IP is invalid\")\n\t\tif rtt != 0 {\n\t\t\tt.Error(\"Round-trip time returned on failure should've been 0\")\n\t\t}\n\t}\n\tif success, rtt := Ping(\"192.168.152.153\", &Config{Timeout: 500 * time.Millisecond}); success {\n\t\tt.Error(\"expected false, because the IP is valid but the host should be unreachable\")\n\t\tif rtt != 0 {\n\t\t\tt.Error(\"Round-trip time returned on failure should've been 0\")\n\t\t}\n\t}\n\t// Can't perform integration tests (e.g. pinging public targets by single-stacked hostname) here,\n\t// because ICMP is blocked in the network of GitHub-hosted runners.\n\tif success, rtt := Ping(\"127.0.0.1\", &Config{Timeout: 500 * time.Millisecond, Network: \"ip\"}); !success {\n\t\tt.Error(\"expected true\")\n\t\tif rtt == 0 {\n\t\t\tt.Error(\"Round-trip time returned on failure should've been 0\")\n\t\t}\n\t}\n\tif success, rtt := Ping(\"::1\", &Config{Timeout: 500 * time.Millisecond, Network: \"ip\"}); !success {\n\t\tt.Error(\"expected true\")\n\t\tif rtt == 0 {\n\t\t\tt.Error(\"Round-trip time returned on failure should've been 0\")\n\t\t}\n\t}\n\tif success, rtt := Ping(\"::1\", &Config{Timeout: 500 * time.Millisecond, Network: \"ip4\"}); success {\n\t\tt.Error(\"expected false, because the IP isn't an IPv4 address\")\n\t\tif rtt != 0 {\n\t\t\tt.Error(\"Round-trip time returned on failure should've been 0\")\n\t\t}\n\t}\n\tif success, rtt := Ping(\"127.0.0.1\", &Config{Timeout: 500 * time.Millisecond, Network: \"ip6\"}); success {\n\t\tt.Error(\"expected false, because the IP isn't an IPv6 address\")\n\t\tif rtt != 0 {\n\t\t\tt.Error(\"Round-trip time returned on failure should've been 0\")\n\t\t}\n\t}\n}\n\nfunc TestShouldRunPingerAsPrivileged(t *testing.T) {\n\t// Don't run in parallel since we're testing system-dependent behavior\n\tif runtime.GOOS == \"windows\" {\n\t\tresult := ShouldRunPingerAsPrivileged()\n\t\tif !result {\n\t\t\tt.Error(\"On Windows, ShouldRunPingerAsPrivileged() should return true\")\n\t\t}\n\t\treturn\n\t}\n\n\t// Non-Windows tests\n\tresult := ShouldRunPingerAsPrivileged()\n\tisRoot := os.Geteuid() == 0\n\n\t// Test cases based on current environment\n\tif isRoot {\n\t\tif !result {\n\t\t\tt.Error(\"When running as root, ShouldRunPingerAsPrivileged() should return true\")\n\t\t}\n\t} else {\n\t\t// When not root, the result depends on raw socket creation\n\t\t// We can at least verify the function runs without panic\n\t\tt.Logf(\"Non-root privileged result: %v\", result)\n\t}\n}\n\nfunc TestCanPerformStartTLS(t *testing.T) {\n\ttype args struct {\n\t\taddress     string\n\t\tinsecure    bool\n\t\tdnsresolver string\n\t}\n\ttests := []struct {\n\t\tname          string\n\t\targs          args\n\t\twantConnected bool\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\tname: \"invalid address\",\n\t\t\targs: args{\n\t\t\t\taddress: \"test\",\n\t\t\t},\n\t\t\twantConnected: false,\n\t\t\twantErr:       true,\n\t\t},\n\t\t{\n\t\t\tname: \"error dial\",\n\t\t\targs: args{\n\t\t\t\taddress: \"test:1234\",\n\t\t\t},\n\t\t\twantConnected: false,\n\t\t\twantErr:       true,\n\t\t},\n\t\t{\n\t\t\tname: \"valid starttls\",\n\t\t\targs: args{\n\t\t\t\taddress: \"smtp.gmail.com:587\",\n\t\t\t},\n\t\t\twantConnected: true,\n\t\t\twantErr:       false,\n\t\t},\n\t\t{\n\t\t\tname: \"dns resolver\",\n\t\t\targs: args{\n\t\t\t\taddress:     \"smtp.gmail.com:587\",\n\t\t\t\tdnsresolver: \"tcp://1.1.1.1:53\",\n\t\t\t},\n\t\t\twantConnected: true,\n\t\t\twantErr:       false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tconnected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, DNSResolver: tt.args.dnsresolver})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"CanPerformStartTLS() err=%v, wantErr=%v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif connected != tt.wantConnected {\n\t\t\t\tt.Errorf(\"CanPerformStartTLS() connected=%v, wantConnected=%v\", connected, tt.wantConnected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCanPerformTLS(t *testing.T) {\n\ttype args struct {\n\t\taddress  string\n\t\tinsecure bool\n\t}\n\ttests := []struct {\n\t\tname          string\n\t\targs          args\n\t\twantConnected bool\n\t\twantErr       bool\n\t}{\n\t\t{\n\t\t\tname: \"invalid address\",\n\t\t\targs: args{\n\t\t\t\taddress: \"test\",\n\t\t\t},\n\t\t\twantConnected: false,\n\t\t\twantErr:       true,\n\t\t},\n\t\t{\n\t\t\tname: \"error dial\",\n\t\t\targs: args{\n\t\t\t\taddress: \"test:1234\",\n\t\t\t},\n\t\t\twantConnected: false,\n\t\t\twantErr:       true,\n\t\t},\n\t\t{\n\t\t\tname: \"valid tls\",\n\t\t\targs: args{\n\t\t\t\taddress: \"smtp.gmail.com:465\",\n\t\t\t},\n\t\t\twantConnected: true,\n\t\t\twantErr:       false,\n\t\t},\n\t\t{\n\t\t\tname: \"bad cert with insecure true\",\n\t\t\targs: args{\n\t\t\t\taddress:  \"expired.badssl.com:443\",\n\t\t\t\tinsecure: true,\n\t\t\t},\n\t\t\twantConnected: true,\n\t\t\twantErr:       false,\n\t\t},\n\t\t{\n\t\t\tname: \"bad cert with insecure false\",\n\t\t\targs: args{\n\t\t\t\taddress:  \"expired.badssl.com:443\",\n\t\t\t\tinsecure: false,\n\t\t\t},\n\t\t\twantConnected: false,\n\t\t\twantErr:       true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tconnected, _, _, err := CanPerformTLS(tt.args.address, \"\", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"CanPerformTLS() err=%v, wantErr=%v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif connected != tt.wantConnected {\n\t\t\t\tt.Errorf(\"CanPerformTLS() connected=%v, wantConnected=%v\", connected, tt.wantConnected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCanCreateConnection(t *testing.T) {\n\tt.Parallel()\n\tconnected, _ := CanCreateNetworkConnection(\"tcp\", \"127.0.0.1\", \"\", &Config{Timeout: 5 * time.Second})\n\tif connected {\n\t\tt.Error(\"should've failed, because there's no port in the address\")\n\t}\n\tconnected, _ = CanCreateNetworkConnection(\"tcp\", \"1.1.1.1:53\", \"\", &Config{Timeout: 5 * time.Second})\n\tif !connected {\n\t\tt.Error(\"should've succeeded, because that IP should always™ be up\")\n\t}\n}\n\n// This test checks if a HTTP client configured with `configureOAuth2()` automatically\n// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization`\n// header to all outgoing HTTP calls.\nfunc TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {\n\tt.Parallel()\n\tdefer InjectHTTPClient(nil)\n\toAuth2Config := &OAuth2Config{\n\t\tClientID:     \"00000000-0000-0000-0000-000000000000\",\n\t\tClientSecret: \"secretsauce\",\n\t\tTokenURL:     \"https://token-server.local/token\",\n\t\tScopes:       []string{\"https://application.local/.default\"},\n\t}\n\tmockHttpClient := &http.Client{\n\t\tTransport: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t// if the mock HTTP client tries to get a token from the `token-server`\n\t\t\t// we provide the expected token response\n\t\t\tif r.Host == \"token-server.local\" {\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tBody: io.NopCloser(bytes.NewReader(\n\t\t\t\t\t\t[]byte(\n\t\t\t\t\t\t\t`{\"token_type\":\"Bearer\",\"expires_in\":3599,\"ext_expires_in\":3599,\"access_token\":\"secret-token\"}`,\n\t\t\t\t\t\t),\n\t\t\t\t\t)),\n\t\t\t\t}\n\t\t\t}\n\t\t\t// to verify the headers were sent as expected, we echo them back in the\n\t\t\t// `X-Org-Authorization` header and check if the token value matches our\n\t\t\t// mocked `token-server` response\n\t\t\treturn &http.Response{\n\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\tHeader: map[string][]string{\n\t\t\t\t\t\"X-Org-Authorization\": {r.Header.Get(\"Authorization\")},\n\t\t\t\t},\n\t\t\t\tBody: http.NoBody,\n\t\t\t}\n\t\t}),\n\t}\n\tmockHttpClientWithOAuth := configureOAuth2(mockHttpClient, *oAuth2Config)\n\tInjectHTTPClient(mockHttpClientWithOAuth)\n\trequest, err := http.NewRequest(http.MethodPost, \"http://127.0.0.1:8282\", http.NoBody)\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tresponse, err := mockHttpClientWithOAuth.Do(request)\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif response.Header == nil {\n\t\tt.Error(\"expected response headers, but got nil\")\n\t}\n\t// the mock response echos the Authorization header used in the request back\n\t// to us as `X-Org-Authorization` header, we check here if the value matches\n\t// our expected token `secret-token`\n\tif response.Header.Get(\"X-Org-Authorization\") != \"Bearer secret-token\" {\n\t\tt.Error(\"expected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got\", response.Header.Get(\"X-Org-Authorization\"))\n\t}\n}\n\nfunc TestQueryWebSocket(t *testing.T) {\n\tt.Parallel()\n\t_, _, err := QueryWebSocket(\"\", \"body\", nil, &Config{Timeout: 2 * time.Second})\n\tif err == nil {\n\t\tt.Error(\"expected an error due to the address being invalid\")\n\t}\n\t_, _, err = QueryWebSocket(\"ws://example.org\", \"body\", nil, &Config{Timeout: 2 * time.Second})\n\tif err == nil {\n\t\tt.Error(\"expected an error due to the target not being websocket-friendly\")\n\t}\n}\n\nfunc TestTlsRenegotiation(t *testing.T) {\n\tt.Parallel()\n\tscenarios := []struct {\n\t\tname           string\n\t\tcfg            TLSConfig\n\t\texpectedConfig tls.RenegotiationSupport\n\t}{\n\t\t{\n\t\t\tname:           \"default\",\n\t\t\tcfg:            TLSConfig{CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"../testdata/cert.key\"},\n\t\t\texpectedConfig: tls.RenegotiateNever,\n\t\t},\n\t\t{\n\t\t\tname:           \"never\",\n\t\t\tcfg:            TLSConfig{RenegotiationSupport: \"never\", CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"../testdata/cert.key\"},\n\t\t\texpectedConfig: tls.RenegotiateNever,\n\t\t},\n\t\t{\n\t\t\tname:           \"once\",\n\t\t\tcfg:            TLSConfig{RenegotiationSupport: \"once\", CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"../testdata/cert.key\"},\n\t\t\texpectedConfig: tls.RenegotiateOnceAsClient,\n\t\t},\n\t\t{\n\t\t\tname:           \"freely\",\n\t\t\tcfg:            TLSConfig{RenegotiationSupport: \"freely\", CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"../testdata/cert.key\"},\n\t\t\texpectedConfig: tls.RenegotiateFreelyAsClient,\n\t\t},\n\t\t{\n\t\t\tname:           \"not-valid-and-broken\",\n\t\t\tcfg:            TLSConfig{RenegotiationSupport: \"invalid\", CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"../testdata/cert.key\"},\n\t\t\texpectedConfig: tls.RenegotiateNever,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\ttls := &tls.Config{}\n\t\t\ttlsConfig := configureTLS(tls, scenario.cfg)\n\t\t\tif tlsConfig.Renegotiation != scenario.expectedConfig {\n\t\t\t\tt.Errorf(\"expected tls renegotiation to be %v, but got %v\", scenario.expectedConfig, tls.Renegotiation)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestQueryDNS(t *testing.T) {\n\tt.Parallel()\n\tscenarios := []struct {\n\t\tname            string\n\t\tinputDNS        dns.Config\n\t\tinputURL        string\n\t\texpectedDNSCode string\n\t\texpectedBody    string\n\t\tisErrExpected   bool\n\t}{\n\t\t{\n\t\t\tname: \"test Config with type A\",\n\t\t\tinputDNS: dns.Config{\n\t\t\t\tQueryType: \"A\",\n\t\t\t\tQueryName: \"example.com.\",\n\t\t\t},\n\t\t\tinputURL:        \"8.8.8.8\",\n\t\t\texpectedDNSCode: \"NOERROR\",\n\t\t\texpectedBody:    \"__IPV4__\",\n\t\t},\n\t\t{\n\t\t\tname: \"test Config with type AAAA\",\n\t\t\tinputDNS: dns.Config{\n\t\t\t\tQueryType: \"AAAA\",\n\t\t\t\tQueryName: \"example.com.\",\n\t\t\t},\n\t\t\tinputURL:        \"8.8.8.8\",\n\t\t\texpectedDNSCode: \"NOERROR\",\n\t\t\texpectedBody:    \"__IPV6__\",\n\t\t},\n\t\t{\n\t\t\tname: \"test Config with type CNAME\",\n\t\t\tinputDNS: dns.Config{\n\t\t\t\tQueryType: \"CNAME\",\n\t\t\t\tQueryName: \"en.wikipedia.org.\",\n\t\t\t},\n\t\t\tinputURL:        \"8.8.8.8\",\n\t\t\texpectedDNSCode: \"NOERROR\",\n\t\t\texpectedBody:    \"dyna.wikimedia.org.\",\n\t\t},\n\t\t{\n\t\t\tname: \"test Config with type MX\",\n\t\t\tinputDNS: dns.Config{\n\t\t\t\tQueryType: \"MX\",\n\t\t\t\tQueryName: \"example.com.\",\n\t\t\t},\n\t\t\tinputURL:        \"8.8.8.8\",\n\t\t\texpectedDNSCode: \"NOERROR\",\n\t\t\texpectedBody:    \".\",\n\t\t},\n\t\t{\n\t\t\tname: \"test Config with type NS\",\n\t\t\tinputDNS: dns.Config{\n\t\t\t\tQueryType: \"NS\",\n\t\t\t\tQueryName: \"example.com.\",\n\t\t\t},\n\t\t\tinputURL:        \"8.8.8.8\",\n\t\t\texpectedDNSCode: \"NOERROR\",\n\t\t\texpectedBody:    \"*.ns.cloudflare.com.\",\n\t\t},\n\t\t{\n\t\t\tname: \"test Config with type PTR\",\n\t\t\tinputDNS: dns.Config{\n\t\t\t\tQueryType: \"PTR\",\n\t\t\t\tQueryName: \"8.8.8.8.in-addr.arpa.\",\n\t\t\t},\n\t\t\tinputURL:        \"8.8.8.8\",\n\t\t\texpectedDNSCode: \"NOERROR\",\n\t\t\texpectedBody:    \"dns.google.\",\n\t\t},\n\t\t{\n\t\t\tname: \"test Config with type PTR and forward IP / no in-addr\",\n\t\t\tinputDNS: dns.Config{\n\t\t\t\tQueryType: \"PTR\",\n\t\t\t\tQueryName: \"1.0.0.1\",\n\t\t\t},\n\t\t\tinputURL:        \"1.1.1.1\",\n\t\t\texpectedDNSCode: \"NOERROR\",\n\t\t\texpectedBody:    \"one.one.one.one.\",\n\t\t},\n\t\t{\n\t\t\tname: \"test Config with fake type and retrieve error\",\n\t\t\tinputDNS: dns.Config{\n\t\t\t\tQueryType: \"B\",\n\t\t\t\tQueryName: \"example\",\n\t\t\t},\n\t\t\tinputURL:      \"8.8.8.8\",\n\t\t\tisErrExpected: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\t_, dnsRCode, body, err := QueryDNS(scenario.inputDNS.QueryType, scenario.inputDNS.QueryName, scenario.inputURL)\n\t\t\tif scenario.isErrExpected && err == nil {\n\t\t\t\tt.Errorf(\"there should be an error\")\n\t\t\t}\n\t\t\tif dnsRCode != scenario.expectedDNSCode {\n\t\t\t\tt.Errorf(\"expected DNSRCode to be %s, got %s\", scenario.expectedDNSCode, dnsRCode)\n\t\t\t}\n\t\t\tif scenario.inputDNS.QueryType == \"NS\" {\n\t\t\t\t// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix\n\t\t\t\tif !pattern.Match(scenario.expectedBody, string(body)) {\n\t\t\t\t\tt.Errorf(\"got %s, expected result %s,\", string(body), scenario.expectedBody)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif string(body) != scenario.expectedBody {\n\t\t\t\t\t// little hack to validate arbitrary ipv4/ipv6\n\t\t\t\t\tswitch scenario.expectedBody {\n\t\t\t\t\tcase \"__IPV4__\":\n\t\t\t\t\t\tif addr, err := netip.ParseAddr(string(body)); err != nil {\n\t\t\t\t\t\t\tt.Errorf(\"got %s, expected result %s\", string(body), scenario.expectedBody)\n\t\t\t\t\t\t} else if !addr.Is4() {\n\t\t\t\t\t\t\tt.Errorf(\"got %s, expected valid IPv4\", string(body))\n\t\t\t\t\t\t}\n\t\t\t\t\tcase \"__IPV6__\":\n\t\t\t\t\t\tif addr, err := netip.ParseAddr(string(body)); err != nil {\n\t\t\t\t\t\t\tt.Errorf(\"got %s, expected result %s\", string(body), scenario.expectedBody)\n\t\t\t\t\t\t} else if !addr.Is6() {\n\t\t\t\t\t\t\tt.Errorf(\"got %s, expected valid IPv6\", string(body))\n\t\t\t\t\t\t}\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tt.Errorf(\"got %s, expected result %s\", string(body), scenario.expectedBody)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc TestCheckSSHBanner(t *testing.T) {\n\tt.Parallel()\n\tcfg := &Config{Timeout: 3}\n\tt.Run(\"no-auth-ssh\", func(t *testing.T) {\n\t\tconnected, status, err := CheckSSHBanner(\"tty.sdf.org\", cfg)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Expected: error != nil, got: %v \", err)\n\t\t}\n\t\tif connected == false {\n\t\t\tt.Errorf(\"Expected: connected == true, got: %v\", connected)\n\t\t}\n\t\tif status != 0 {\n\t\t\tt.Errorf(\"Expected: 0, got: %v\", status)\n\t\t}\n\t})\n\tt.Run(\"invalid-address\", func(t *testing.T) {\n\t\tconnected, status, err := CheckSSHBanner(\"idontplaytheodds.com\", cfg)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"Expected: error, got: %v \", err)\n\t\t}\n\t\tif connected != false {\n\t\t\tt.Errorf(\"Expected: connected == false, got: %v\", connected)\n\t\t}\n\t\tif status != 1 {\n\t\t\tt.Errorf(\"Expected: 1, got: %v\", status)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "client/config.go",
    "content": "package client\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel\"\n\t\"github.com/TwiN/logr\"\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/clientcredentials\"\n\t\"google.golang.org/api/idtoken\"\n)\n\nconst (\n\tdefaultTimeout = 10 * time.Second\n)\n\nvar (\n\tErrInvalidDNSResolver        = errors.New(\"invalid DNS resolver specified. Required format is {proto}://{ip}:{port}\")\n\tErrInvalidDNSResolverPort    = errors.New(\"invalid DNS resolver port\")\n\tErrInvalidClientOAuth2Config = errors.New(\"invalid oauth2 configuration: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)\")\n\tErrInvalidClientIAPConfig    = errors.New(\"invalid Identity-Aware-Proxy configuration: must define all fields for Google Identity-Aware-Proxy programmatic authentication (audience)\")\n\tErrInvalidClientTLSConfig    = errors.New(\"invalid TLS configuration: certificate-file and private-key-file must be specified\")\n\n\tdefaultConfig = Config{\n\t\tInsecure:       false,\n\t\tIgnoreRedirect: false,\n\t\tTimeout:        defaultTimeout,\n\t\tNetwork:        \"ip\",\n\t}\n)\n\n// GetDefaultConfig returns a copy of the default configuration\nfunc GetDefaultConfig() *Config {\n\tcfg := defaultConfig\n\treturn &cfg\n}\n\n// Config is the configuration for clients\ntype Config struct {\n\t// ProxyURL is the URL of the proxy to use for the client\n\tProxyURL string `yaml:\"proxy-url,omitempty\"`\n\n\t// Insecure determines whether to skip verifying the server's certificate chain and host name\n\tInsecure bool `yaml:\"insecure,omitempty\"`\n\n\t// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)\n\tIgnoreRedirect bool `yaml:\"ignore-redirect,omitempty\"`\n\n\t// Timeout for the client\n\tTimeout time.Duration `yaml:\"timeout\"`\n\n\t// DNSResolver override for the HTTP client\n\t// Expected format is {protocol}://{host}:{port}, e.g. tcp://8.8.8.8:53\n\tDNSResolver string `yaml:\"dns-resolver,omitempty\"`\n\n\t// OAuth2Config is the OAuth2 configuration used for the client.\n\t//\n\t// If non-nil, the http.Client returned by getHTTPClient will automatically retrieve a token if necessary.\n\t// See configureOAuth2 for more details.\n\tOAuth2Config *OAuth2Config `yaml:\"oauth2,omitempty\"`\n\n\t// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)\n\tIAPConfig *IAPConfig `yaml:\"identity-aware-proxy,omitempty\"`\n\n\t// Network (ip, ip4 or ip6) for the ICMP client\n\tNetwork string `yaml:\"network\"`\n\n\t// TLS configuration (optional)\n\tTLS *TLSConfig `yaml:\"tls,omitempty\"`\n\n\t// Tunnel is the name of the SSH tunnel to use for the client\n\tTunnel string `yaml:\"tunnel,omitempty\"`\n\n\t// ResolvedTunnel is the resolved SSH tunnel for this specific Config\n\tResolvedTunnel *sshtunnel.SSHTunnel `yaml:\"-\"`\n\n\thttpClient *http.Client\n}\n\n// DNSResolverConfig is the parsed configuration from the DNSResolver config string.\ntype DNSResolverConfig struct {\n\tProtocol string\n\tHost     string\n\tPort     string\n}\n\n// OAuth2Config is the configuration for the OAuth2 client credentials flow\ntype OAuth2Config struct {\n\tTokenURL     string   `yaml:\"token-url\"` // e.g. https://dev-12345678.okta.com/token\n\tClientID     string   `yaml:\"client-id\"`\n\tClientSecret string   `yaml:\"client-secret\"`\n\tScopes       []string `yaml:\"scopes\"` // e.g. [\"openid\"]\n}\n\n// IAPConfig is the configuration for the Google Cloud Identity-Aware-Proxy\ntype IAPConfig struct {\n\tAudience string `yaml:\"audience\"` // e.g. \"toto.apps.googleusercontent.com\"\n}\n\n// TLSConfig is the configuration for mTLS configurations\ntype TLSConfig struct {\n\t// CertificateFile is the public certificate for TLS in PEM format.\n\tCertificateFile string `yaml:\"certificate-file,omitempty\"`\n\n\t// PrivateKeyFile is the private key file for TLS in PEM format.\n\tPrivateKeyFile string `yaml:\"private-key-file,omitempty\"`\n\n\tRenegotiationSupport string `yaml:\"renegotiation,omitempty\"`\n}\n\n// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary\nfunc (c *Config) ValidateAndSetDefaults() error {\n\tif c.Timeout < time.Millisecond {\n\t\tc.Timeout = 10 * time.Second\n\t}\n\tif c.HasCustomDNSResolver() {\n\t\t// Validate the DNS resolver now to make sure it will not return an error later.\n\t\tif _, err := c.parseDNSResolver(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif c.HasOAuth2Config() && !c.OAuth2Config.isValid() {\n\t\treturn ErrInvalidClientOAuth2Config\n\t}\n\tif c.HasIAPConfig() && !c.IAPConfig.isValid() {\n\t\treturn ErrInvalidClientIAPConfig\n\t}\n\tif c.HasTLSConfig() {\n\t\tif err := c.TLS.isValid(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// HasCustomDNSResolver returns whether a custom DNSResolver is configured\nfunc (c *Config) HasCustomDNSResolver() bool {\n\treturn len(c.DNSResolver) > 0\n}\n\n// parseDNSResolver parses the DNS resolver into the DNSResolverConfig struct\nfunc (c *Config) parseDNSResolver() (*DNSResolverConfig, error) {\n\tre := regexp.MustCompile(`^(?P<proto>(.*))://(?P<host>[A-Za-z0-9\\-\\.]+):(?P<port>[0-9]+)?(.*)$`)\n\tmatches := re.FindStringSubmatch(c.DNSResolver)\n\tif len(matches) == 0 {\n\t\treturn nil, ErrInvalidDNSResolver\n\t}\n\tr := make(map[string]string)\n\tfor i, k := range re.SubexpNames() {\n\t\tif i != 0 && k != \"\" {\n\t\t\tr[k] = matches[i]\n\t\t}\n\t}\n\tport, err := strconv.Atoi(r[\"port\"])\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif port < 1 || port > 65535 {\n\t\treturn nil, ErrInvalidDNSResolverPort\n\t}\n\treturn &DNSResolverConfig{\n\t\tProtocol: r[\"proto\"],\n\t\tHost:     r[\"host\"],\n\t\tPort:     r[\"port\"],\n\t}, nil\n}\n\n// HasOAuth2Config returns true if the client has OAuth2 configuration parameters\nfunc (c *Config) HasOAuth2Config() bool {\n\treturn c.OAuth2Config != nil\n}\n\n// HasIAPConfig returns true if the client has IAP configuration parameters\nfunc (c *Config) HasIAPConfig() bool {\n\treturn c.IAPConfig != nil\n}\n\n// HasTLSConfig returns true if the client has client certificate parameters\nfunc (c *Config) HasTLSConfig() bool {\n\treturn c.TLS != nil && len(c.TLS.CertificateFile) > 0 && len(c.TLS.PrivateKeyFile) > 0\n}\n\n// isValid() returns true if the IAP configuration is valid\nfunc (c *IAPConfig) isValid() bool {\n\treturn len(c.Audience) > 0\n}\n\n// isValid() returns true if the OAuth2 configuration is valid\nfunc (c *OAuth2Config) isValid() bool {\n\treturn len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0\n}\n\n// isValid() returns nil if the client tls certificates are valid, otherwise returns an error\nfunc (t *TLSConfig) isValid() error {\n\tif len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 {\n\t\t_, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}\n\treturn ErrInvalidClientTLSConfig\n}\n\n// getHTTPClient return an HTTP client matching the Config's parameters.\nfunc (c *Config) getHTTPClient() *http.Client {\n\ttlsConfig := &tls.Config{\n\t\tInsecureSkipVerify: c.Insecure,\n\t}\n\tif c.HasTLSConfig() && c.TLS.isValid() == nil {\n\t\ttlsConfig = configureTLS(tlsConfig, *c.TLS)\n\t}\n\tif c.httpClient == nil {\n\t\tc.httpClient = &http.Client{\n\t\t\tTimeout: c.Timeout,\n\t\t\tTransport: &http.Transport{\n\t\t\t\tMaxIdleConns:        100,\n\t\t\t\tMaxIdleConnsPerHost: 20,\n\t\t\t\tProxy:               http.ProxyFromEnvironment,\n\t\t\t\tTLSClientConfig:     tlsConfig,\n\t\t\t},\n\t\t\tCheckRedirect: func(req *http.Request, via []*http.Request) error {\n\t\t\t\tif c.IgnoreRedirect {\n\t\t\t\t\t// Don't follow redirects\n\t\t\t\t\treturn http.ErrUseLastResponse\n\t\t\t\t}\n\t\t\t\t// Follow redirects\n\t\t\t\treturn nil\n\t\t\t},\n\t\t}\n\t\tif c.ProxyURL != \"\" {\n\t\t\tproxyURL, err := url.Parse(c.ProxyURL)\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring custom proxy due to error: %s\", err.Error())\n\t\t\t} else {\n\t\t\t\tc.httpClient.Transport.(*http.Transport).Proxy = http.ProxyURL(proxyURL)\n\t\t\t}\n\t\t}\n\t\tif c.HasCustomDNSResolver() {\n\t\t\tdnsResolver, err := c.parseDNSResolver()\n\t\t\tif err != nil {\n\t\t\t\t// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.\n\t\t\t\t// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)\n\t\t\t\tlogr.Errorf(\"[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error: %s\", err.Error())\n\t\t\t} else {\n\t\t\t\tdialer := &net.Dialer{\n\t\t\t\t\tResolver: &net.Resolver{\n\t\t\t\t\t\tPreferGo: true,\n\t\t\t\t\t\tDial: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\t\t\t\t\t\td := net.Dialer{}\n\t\t\t\t\t\t\treturn d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+\":\"+dnsResolver.Port)\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tc.httpClient.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\t\t\treturn dialer.DialContext(ctx, network, addr)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif c.HasOAuth2Config() && c.HasIAPConfig() {\n\t\t\tlogr.Errorf(\"[client.getHTTPClient] Error: Both Identity-Aware-Proxy and Oauth2 configuration are present.\")\n\t\t} else if c.HasOAuth2Config() {\n\t\t\tc.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)\n\t\t} else if c.HasIAPConfig() {\n\t\t\tc.httpClient = configureIAP(c.httpClient, *c.IAPConfig)\n\t\t}\n\t\tif c.ResolvedTunnel != nil {\n\t\t\t// Use SSH tunnel dialer\n\t\t\tif transport, ok := c.httpClient.Transport.(*http.Transport); ok {\n\t\t\t\ttransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\t\t\treturn c.ResolvedTunnel.Dial(network, addr)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn c.httpClient\n}\n\n// validateIAPToken returns a boolean that will define if the Google identity-aware-proxy token can be fetched\n// and if is it valid.\nfunc validateIAPToken(ctx context.Context, c IAPConfig) bool {\n\tts, err := idtoken.NewTokenSource(ctx, c.Audience)\n\tif err != nil {\n\t\tlogr.Errorf(\"[client.ValidateIAPToken] Claiming Identity token failed: %s\", err.Error())\n\t\treturn false\n\t}\n\ttok, err := ts.Token()\n\tif err != nil {\n\t\tlogr.Errorf(\"[client.ValidateIAPToken] Get Identity-Aware-Proxy token failed: %s\", err.Error())\n\t\treturn false\n\t}\n\t_, err = idtoken.Validate(ctx, tok.AccessToken, c.Audience)\n\tif err != nil {\n\t\tlogr.Errorf(\"[client.ValidateIAPToken] Token Validation failed: %s\", err.Error())\n\t\treturn false\n\t}\n\treturn true\n}\n\n// configureIAP returns an HTTP client that will obtain and refresh Identity-Aware-Proxy tokens as necessary.\n// The returned Client and its Transport should not be modified.\nfunc configureIAP(httpClient *http.Client, c IAPConfig) *http.Client {\n\tctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)\n\tif validateIAPToken(ctx, c) {\n\t\tts, err := idtoken.NewTokenSource(ctx, c.Audience)\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[client.configureIAP] Claiming Token Source failed: %s\", err.Error())\n\t\t\treturn httpClient\n\t\t}\n\t\tclient := oauth2.NewClient(ctx, ts)\n\t\tclient.Timeout = httpClient.Timeout\n\t\treturn client\n\t}\n\treturn httpClient\n}\n\n// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.\n// The returned Client and its Transport should not be modified.\nfunc configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {\n\toauth2cfg := clientcredentials.Config{\n\t\tClientID:     c.ClientID,\n\t\tClientSecret: c.ClientSecret,\n\t\tScopes:       c.Scopes,\n\t\tTokenURL:     c.TokenURL,\n\t}\n\tctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)\n\tclient := oauth2cfg.Client(ctx)\n\tclient.Timeout = httpClient.Timeout\n\treturn client\n}\n\n// configureTLS returns a TLS Config that will enable mTLS\nfunc configureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config {\n\tclientTLSCert, err := tls.LoadX509KeyPair(c.CertificateFile, c.PrivateKeyFile)\n\tif err != nil {\n\t\tlogr.Errorf(\"[client.configureTLS] Failed to load certificate: %s\", err.Error())\n\t\treturn nil\n\t}\n\ttlsConfig.Certificates = []tls.Certificate{clientTLSCert}\n\ttlsConfig.Renegotiation = tls.RenegotiateNever\n\trenegotiationSupport := map[string]tls.RenegotiationSupport{\n\t\t\"once\":   tls.RenegotiateOnceAsClient,\n\t\t\"freely\": tls.RenegotiateFreelyAsClient,\n\t\t\"never\":  tls.RenegotiateNever,\n\t}\n\tif val, ok := renegotiationSupport[c.RenegotiationSupport]; ok {\n\t\ttlsConfig.Renegotiation = val\n\t}\n\treturn tlsConfig\n}\n"
  },
  {
    "path": "client/config_test.go",
    "content": "package client\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestConfig_getHTTPClient(t *testing.T) {\n\tinsecureConfig := &Config{Insecure: true}\n\tinsecureConfig.ValidateAndSetDefaults()\n\tinsecureClient := insecureConfig.getHTTPClient()\n\tif !(insecureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {\n\t\tt.Error(\"expected Config.Insecure set to true to cause the HTTP client to skip certificate verification\")\n\t}\n\tif insecureClient.Timeout != defaultTimeout {\n\t\tt.Error(\"expected Config.Timeout to default the HTTP client to a timeout of 10s\")\n\t}\n\trequest, _ := http.NewRequest(\"GET\", \"\", nil)\n\tif err := insecureClient.CheckRedirect(request, nil); err != nil {\n\t\tt.Error(\"expected Config.IgnoreRedirect set to false to cause the HTTP client's CheckRedirect to return nil\")\n\t}\n\n\tsecureConfig := &Config{IgnoreRedirect: true, Timeout: 5 * time.Second}\n\tsecureConfig.ValidateAndSetDefaults()\n\tsecureClient := secureConfig.getHTTPClient()\n\tif (secureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {\n\t\tt.Error(\"expected Config.Insecure set to false to cause the HTTP client to not skip certificate verification\")\n\t}\n\tif secureClient.Timeout != 5*time.Second {\n\t\tt.Error(\"expected Config.Timeout to cause the HTTP client to have a timeout of 5s\")\n\t}\n\trequest, _ = http.NewRequest(\"GET\", \"\", nil)\n\tif err := secureClient.CheckRedirect(request, nil); err != http.ErrUseLastResponse {\n\t\tt.Error(\"expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse\")\n\t}\n}\n\nfunc TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {\n\ttype args struct {\n\t\tdnsResolver string\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"with-valid-resolver\",\n\t\t\targs: args{\n\t\t\t\tdnsResolver: \"tcp://1.1.1.1:53\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"with-invalid-resolver-port\",\n\t\t\targs: args{\n\t\t\t\tdnsResolver: \"tcp://127.0.0.1:99999\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"with-invalid-resolver-format\",\n\t\t\targs: args{\n\t\t\t\tdnsResolver: \"foobar\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &Config{\n\t\t\t\tDNSResolver: tt.args.dnsResolver,\n\t\t\t}\n\t\t\terr := cfg.ValidateAndSetDefaults()\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ValidateAndSetDefaults() error=%v, wantErr=%v\", err, tt.wantErr)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_getHTTPClient_withCustomProxyURL(t *testing.T) {\n\tproxyURL := \"http://proxy.example.com:8080\"\n\tcfg := &Config{\n\t\tProxyURL: proxyURL,\n\t}\n\tcfg.ValidateAndSetDefaults()\n\tclient := cfg.getHTTPClient()\n\ttransport := client.Transport.(*http.Transport)\n\tif transport.Proxy == nil {\n\t\tt.Errorf(\"expected Config.ProxyURL to set the HTTP client's proxy to %s\", proxyURL)\n\t}\n\treq := &http.Request{\n\t\tURL: &url.URL{\n\t\t\tScheme: \"http\",\n\t\t\tHost:   \"www.example.com\",\n\t\t},\n\t}\n\texpectProxyURL, err := transport.Proxy(req)\n\tif err != nil {\n\t\tt.Errorf(\"can't proxy the request %s\", proxyURL)\n\t}\n\tif proxyURL != expectProxyURL.String() {\n\t\tt.Errorf(\"expected Config.ProxyURL to set the HTTP client's proxy to %s\", proxyURL)\n\t}\n}\n\nfunc TestConfig_TlsIsValid(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tcfg         *Config\n\t\texpectedErr bool\n\t}{\n\t\t{\n\t\t\tname:        \"good-tls-config\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"../testdata/cert.key\"}},\n\t\t\texpectedErr: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"missing-certificate-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"doesnotexist\", PrivateKeyFile: \"../testdata/cert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"bad-certificate-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../testdata/badcert.pem\", PrivateKeyFile: \"../testdata/cert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"no-certificate-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"\", PrivateKeyFile: \"../testdata/cert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"missing-private-key-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"doesnotexist\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"no-private-key-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"bad-private-key-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"../testdata/badcert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"bad-certificate-and-private-key-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../testdata/badcert.pem\", PrivateKeyFile: \"../testdata/badcert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t}\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\terr := test.cfg.TLS.isValid()\n\t\t\tif (err != nil) != test.expectedErr {\n\t\t\t\tt.Errorf(\"expected the existence of an error to be %v, got %v\", test.expectedErr, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !test.expectedErr {\n\t\t\t\tif test.cfg.TLS.isValid() != nil {\n\t\t\t\t\tt.Error(\"cfg.TLS.isValid() returned an error even though no error was expected\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "client/grpc.go",
    "content": "package client\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/TwiN/logr\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/credentials\"\n\t\"google.golang.org/grpc/credentials/insecure\"\n\thealth \"google.golang.org/grpc/health/grpc_health_v1\"\n)\n\n// PerformGRPCHealthCheck dials a gRPC target and performs the standard Health/Check RPC.\n// Returns whether a connection was established, the serving status string, an error (if any), and the elapsed duration.\nfunc PerformGRPCHealthCheck(address string, useTLS bool, cfg *Config) (bool, string, error, time.Duration) {\n\tif cfg == nil {\n\t\tcfg = GetDefaultConfig()\n\t}\n\tctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)\n\tdefer cancel()\n\n\tvar opts []grpc.DialOption\n\t// Transport credentials\n\tif useTLS {\n\t\ttlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure}\n\t\tif cfg.HasTLSConfig() && cfg.TLS.isValid() == nil {\n\t\t\ttlsCfg = configureTLS(tlsCfg, *cfg.TLS)\n\t\t}\n\t\topts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))\n\t} else {\n\t\topts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))\n\t}\n\t// Custom dialer for DNS resolver or SSH tunnel\n\topts = append(opts, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {\n\t\tif cfg.ResolvedTunnel != nil {\n\t\t\treturn cfg.ResolvedTunnel.Dial(\"tcp\", addr)\n\t\t}\n\t\tif cfg.HasCustomDNSResolver() {\n\t\t\tresolverCfg, err := cfg.parseDNSResolver()\n\t\t\tif err != nil {\n\t\t\t\t// Shouldn't happen because already validated; log and fall back\n\t\t\t\tlogr.Errorf(\"[client.PerformGRPCHealthCheck] invalid DNS resolver: %v\", err)\n\t\t\t} else {\n\t\t\t\td := &net.Dialer{Resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {\n\t\t\t\t\td := net.Dialer{}\n\t\t\t\t\treturn d.DialContext(ctx, resolverCfg.Protocol, resolverCfg.Host+\":\"+resolverCfg.Port)\n\t\t\t\t}}}\n\t\t\t\treturn d.DialContext(ctx, \"tcp\", addr)\n\t\t\t}\n\t\t}\n\t\tvar d net.Dialer\n\t\treturn d.DialContext(ctx, \"tcp\", addr)\n\t}))\n\n\tstart := time.Now()\n\tconn, err := grpc.DialContext(ctx, address, opts...)\n\tif err != nil {\n\t\treturn false, \"\", err, time.Since(start)\n\t}\n\tdefer conn.Close()\n\n\tclient := health.NewHealthClient(conn)\n\tresp, err := client.Check(ctx, &health.HealthCheckRequest{Service: \"\"})\n\tif err != nil {\n\t\treturn false, \"\", err, time.Since(start)\n\t}\n\treturn true, resp.GetStatus().String(), nil, time.Since(start)\n}\n"
  },
  {
    "path": "config/announcement/announcement.go",
    "content": "package announcement\n\nimport (\n\t\"errors\"\n\t\"sort\"\n\t\"time\"\n)\n\nconst (\n\t// TypeOutage represents a service outage\n\tTypeOutage = \"outage\"\n\n\t// TypeWarning represents a warning or potential issue\n\tTypeWarning = \"warning\"\n\n\t// TypeInformation represents general information\n\tTypeInformation = \"information\"\n\n\t// TypeOperational represents operational status or resolved issues\n\tTypeOperational = \"operational\"\n\n\t// TypeNone represents no specific type (default)\n\tTypeNone = \"none\"\n)\n\nvar (\n\t// ErrInvalidAnnouncementType is returned when an invalid announcement type is specified\n\tErrInvalidAnnouncementType = errors.New(\"invalid announcement type\")\n\n\t// ErrEmptyMessage is returned when an announcement has an empty message\n\tErrEmptyMessage = errors.New(\"announcement message cannot be empty\")\n\n\t// ErrMissingTimestamp is returned when an announcement has an empty timestamp\n\tErrMissingTimestamp = errors.New(\"announcement timestamp must be set\")\n\n\t// validTypes contains all valid announcement types\n\tvalidTypes = map[string]bool{\n\t\tTypeOutage:      true,\n\t\tTypeWarning:     true,\n\t\tTypeInformation: true,\n\t\tTypeOperational: true,\n\t\tTypeNone:        true,\n\t}\n)\n\n// Announcement represents a system-wide announcement\ntype Announcement struct {\n\t// Timestamp is the UTC timestamp when the announcement was made\n\tTimestamp time.Time `yaml:\"timestamp\" json:\"timestamp\"`\n\n\t// Type is the type of announcement (outage, warning, information, operational, none)\n\tType string `yaml:\"type\" json:\"type\"`\n\n\t// Message is the user-facing text describing the announcement\n\tMessage string `yaml:\"message\" json:\"message\"`\n\n\t// Archived indicates whether the announcement should be displayed in the historical section\n\t// instead of at the top of the status page\n\tArchived bool `yaml:\"archived,omitempty\" json:\"archived,omitempty\"`\n}\n\n// ValidateAndSetDefaults validates the announcement and sets default values if necessary\nfunc (a *Announcement) ValidateAndSetDefaults() error {\n\t// Validate message\n\tif a.Message == \"\" {\n\t\treturn ErrEmptyMessage\n\t}\n\t// Set default type if empty\n\tif a.Type == \"\" {\n\t\ta.Type = TypeNone\n\t}\n\t// Validate type\n\tif !validTypes[a.Type] {\n\t\treturn ErrInvalidAnnouncementType\n\t}\n\t// If timestamp is zero, return an error\n\tif a.Timestamp.IsZero() {\n\t\treturn ErrMissingTimestamp\n\t}\n\treturn nil\n}\n\n// SortByTimestamp sorts a slice of announcements by timestamp in descending order (newest first)\nfunc SortByTimestamp(announcements []*Announcement) {\n\tsort.Slice(announcements, func(i, j int) bool {\n\t\treturn announcements[i].Timestamp.After(announcements[j].Timestamp)\n\t})\n}\n\n// ValidateAndSetDefaults validates a slice of announcements and sets defaults\nfunc ValidateAndSetDefaults(announcements []*Announcement) error {\n\tfor _, announcement := range announcements {\n\t\tif err := announcement.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "config/announcement/announcement_test.go",
    "content": "package announcement\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAnnouncement_ValidateAndSetDefaults(t *testing.T) {\n\tnow := time.Now()\n\tscenarios := []struct {\n\t\tname          string\n\t\tannouncement  *Announcement\n\t\texpectedError error\n\t\texpectedType  string\n\t}{\n\t\t{\n\t\t\tname: \"valid-announcement-with-all-fields\",\n\t\t\tannouncement: &Announcement{\n\t\t\t\tTimestamp: now,\n\t\t\t\tType:      TypeWarning,\n\t\t\t\tMessage:   \"This is a test announcement\",\n\t\t\t\tArchived:  false,\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t\texpectedType:  TypeWarning,\n\t\t},\n\t\t{\n\t\t\tname: \"valid-announcement-with-archived-true\",\n\t\t\tannouncement: &Announcement{\n\t\t\t\tTimestamp: now,\n\t\t\t\tType:      TypeOperational,\n\t\t\t\tMessage:   \"This is an archived announcement\",\n\t\t\t\tArchived:  true,\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t\texpectedType:  TypeOperational,\n\t\t},\n\t\t{\n\t\t\tname: \"valid-announcement-with-empty-type-should-default-to-none\",\n\t\t\tannouncement: &Announcement{\n\t\t\t\tTimestamp: now,\n\t\t\t\tMessage:   \"This announcement has no type\",\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t\texpectedType:  TypeNone,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-announcement-with-empty-message\",\n\t\t\tannouncement: &Announcement{\n\t\t\t\tTimestamp: now,\n\t\t\t\tType:      TypeWarning,\n\t\t\t\tMessage:   \"\",\n\t\t\t},\n\t\t\texpectedError: ErrEmptyMessage,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-announcement-with-zero-timestamp\",\n\t\t\tannouncement: &Announcement{\n\t\t\t\tTimestamp: time.Time{},\n\t\t\t\tType:      TypeWarning,\n\t\t\t\tMessage:   \"Test message\",\n\t\t\t},\n\t\t\texpectedError: ErrMissingTimestamp,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-announcement-with-invalid-type\",\n\t\t\tannouncement: &Announcement{\n\t\t\t\tTimestamp: now,\n\t\t\t\tType:      \"invalid-type\",\n\t\t\t\tMessage:   \"Test message\",\n\t\t\t},\n\t\t\texpectedError: ErrInvalidAnnouncementType,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.announcement.ValidateAndSetDefaults()\n\t\t\tif !errors.Is(err, scenario.expectedError) {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.expectedError, err)\n\t\t\t}\n\t\t\tif scenario.expectedError == nil && scenario.announcement.Type != scenario.expectedType {\n\t\t\t\tt.Errorf(\"expected type %s, got %s\", scenario.expectedType, scenario.announcement.Type)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAnnouncement_ValidateAndSetDefaults_AllTypes(t *testing.T) {\n\tnow := time.Now()\n\tvalidTypes := []string{TypeOutage, TypeWarning, TypeInformation, TypeOperational, TypeNone}\n\tfor _, validType := range validTypes {\n\t\tt.Run(\"type-\"+validType, func(t *testing.T) {\n\t\t\tannouncement := &Announcement{\n\t\t\t\tTimestamp: now,\n\t\t\t\tType:      validType,\n\t\t\t\tMessage:   \"Test message\",\n\t\t\t}\n\t\t\tif err := announcement.ValidateAndSetDefaults(); err != nil {\n\t\t\t\tt.Errorf(\"expected no error for type %s, got %v\", validType, err)\n\t\t\t}\n\t\t\tif announcement.Type != validType {\n\t\t\t\tt.Errorf(\"expected type %s, got %s\", validType, announcement.Type)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSortByTimestamp(t *testing.T) {\n\tnow := time.Now()\n\tearlier := now.Add(-1 * time.Hour)\n\tlater := now.Add(1 * time.Hour)\n\tannouncements := []*Announcement{\n\t\t{Timestamp: now, Message: \"now\"},\n\t\t{Timestamp: later, Message: \"later\"},\n\t\t{Timestamp: earlier, Message: \"earlier\"},\n\t}\n\tSortByTimestamp(announcements)\n\tif announcements[0].Timestamp != later {\n\t\tt.Error(\"expected first announcement to be the latest\")\n\t}\n\tif announcements[1].Timestamp != now {\n\t\tt.Error(\"expected second announcement to be the middle one\")\n\t}\n\tif announcements[2].Timestamp != earlier {\n\t\tt.Error(\"expected third announcement to be the earliest\")\n\t}\n}\n\nfunc TestSortByTimestamp_WithArchivedField(t *testing.T) {\n\tnow := time.Now()\n\tearlier := now.Add(-1 * time.Hour)\n\tlater := now.Add(1 * time.Hour)\n\tannouncements := []*Announcement{\n\t\t{Timestamp: now, Message: \"now\", Archived: false},\n\t\t{Timestamp: later, Message: \"later\", Archived: true},\n\t\t{Timestamp: earlier, Message: \"earlier\", Archived: false},\n\t}\n\tSortByTimestamp(announcements)\n\t// Sorting should be by timestamp only, not affected by archived status\n\tif announcements[0].Timestamp != later {\n\t\tt.Error(\"expected first announcement to be the latest, regardless of archived status\")\n\t}\n\tif !announcements[0].Archived {\n\t\tt.Error(\"expected first announcement to be archived\")\n\t}\n\tif announcements[1].Timestamp != now {\n\t\tt.Error(\"expected second announcement to be the middle one\")\n\t}\n\tif announcements[2].Timestamp != earlier {\n\t\tt.Error(\"expected third announcement to be the earliest\")\n\t}\n}\n\nfunc TestValidateAndSetDefaults_Slice(t *testing.T) {\n\tnow := time.Now()\n\tscenarios := []struct {\n\t\tname           string\n\t\tannouncements  []*Announcement\n\t\texpectedError  error\n\t\tshouldValidate bool\n\t}{\n\t\t{\n\t\t\tname: \"all-valid-announcements\",\n\t\t\tannouncements: []*Announcement{\n\t\t\t\t{Timestamp: now, Type: TypeWarning, Message: \"First announcement\"},\n\t\t\t\t{Timestamp: now, Type: TypeOperational, Message: \"Second announcement\"},\n\t\t\t},\n\t\t\texpectedError:  nil,\n\t\t\tshouldValidate: true,\n\t\t},\n\t\t{\n\t\t\tname: \"mixed-archived-announcements\",\n\t\t\tannouncements: []*Announcement{\n\t\t\t\t{Timestamp: now, Type: TypeWarning, Message: \"Active announcement\", Archived: false},\n\t\t\t\t{Timestamp: now, Type: TypeOperational, Message: \"Archived announcement\", Archived: true},\n\t\t\t},\n\t\t\texpectedError:  nil,\n\t\t\tshouldValidate: true,\n\t\t},\n\t\t{\n\t\t\tname: \"one-invalid-announcement-in-slice\",\n\t\t\tannouncements: []*Announcement{\n\t\t\t\t{Timestamp: now, Type: TypeWarning, Message: \"Valid announcement\"},\n\t\t\t\t{Timestamp: now, Type: TypeOperational, Message: \"\"},\n\t\t\t},\n\t\t\texpectedError:  ErrEmptyMessage,\n\t\t\tshouldValidate: false,\n\t\t},\n\t\t{\n\t\t\tname: \"announcement-with-missing-timestamp\",\n\t\t\tannouncements: []*Announcement{\n\t\t\t\t{Timestamp: now, Type: TypeWarning, Message: \"Valid announcement\"},\n\t\t\t\t{Timestamp: time.Time{}, Type: TypeOperational, Message: \"Invalid announcement\"},\n\t\t\t},\n\t\t\texpectedError:  ErrMissingTimestamp,\n\t\t\tshouldValidate: false,\n\t\t},\n\t\t{\n\t\t\tname: \"announcement-with-invalid-type\",\n\t\t\tannouncements: []*Announcement{\n\t\t\t\t{Timestamp: now, Type: TypeWarning, Message: \"Valid announcement\"},\n\t\t\t\t{Timestamp: now, Type: \"bad-type\", Message: \"Invalid announcement\"},\n\t\t\t},\n\t\t\texpectedError:  ErrInvalidAnnouncementType,\n\t\t\tshouldValidate: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := ValidateAndSetDefaults(scenario.announcements)\n\t\t\tif !errors.Is(err, scenario.expectedError) {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.expectedError, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAnnouncement_ArchivedFieldDefaults(t *testing.T) {\n\tnow := time.Now()\n\tannouncement := &Announcement{\n\t\tTimestamp: now,\n\t\tType:      TypeWarning,\n\t\tMessage:   \"Test announcement\",\n\t\t// Archived not set, should default to false\n\t}\n\tif err := announcement.ValidateAndSetDefaults(); err != nil {\n\t\tt.Errorf(\"expected no error, got %v\", err)\n\t}\n\t// Zero value for bool is false\n\tif announcement.Archived {\n\t\tt.Error(\"expected Archived to default to false\")\n\t}\n}\n\nfunc TestValidateAndSetDefaults_EmptySlice(t *testing.T) {\n\tannouncements := []*Announcement{}\n\tif err := ValidateAndSetDefaults(announcements); err != nil {\n\t\tt.Errorf(\"expected no error for empty slice, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "config/config.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/deepmerge\"\n\t\"github.com/TwiN/gatus/v5/alerting\"\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/announcement\"\n\t\"github.com/TwiN/gatus/v5/config/connectivity\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/key\"\n\t\"github.com/TwiN/gatus/v5/config/maintenance\"\n\t\"github.com/TwiN/gatus/v5/config/remote\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/config/tunneling\"\n\t\"github.com/TwiN/gatus/v5/config/ui\"\n\t\"github.com/TwiN/gatus/v5/config/web\"\n\t\"github.com/TwiN/gatus/v5/security\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/logr\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nconst (\n\t// DefaultConfigurationFilePath is the default path that will be used to search for the configuration file\n\t// if a custom path isn't configured through the GATUS_CONFIG_PATH environment variable\n\tDefaultConfigurationFilePath = \"config/config.yaml\"\n\n\t// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the\n\t// configuration file if DefaultConfigurationFilePath didn't work\n\tDefaultFallbackConfigurationFilePath = \"config/config.yml\"\n\n\t// DefaultConcurrency is the default number of endpoints/suites that can be monitored concurrently\n\tDefaultConcurrency = 3\n)\n\nvar (\n\t// ErrNoEndpointOrSuiteInConfig is an error returned when a configuration file or directory has no endpoints configured\n\tErrNoEndpointOrSuiteInConfig = errors.New(\"configuration should contain at least one endpoint or suite\")\n\n\t// ErrConfigFileNotFound is an error returned when a configuration file could not be found\n\tErrConfigFileNotFound = errors.New(\"configuration file not found\")\n\n\t// ErrInvalidSecurityConfig is an error returned when the security configuration is invalid\n\tErrInvalidSecurityConfig = errors.New(\"invalid security configuration\")\n\n\t// errEarlyReturn is returned to break out of a loop from a callback early\n\terrEarlyReturn = errors.New(\"early escape\")\n)\n\n// Config is the main configuration structure\ntype Config struct {\n\t// Debug Whether to enable debug logs\n\t// Deprecated: Use the GATUS_LOG_LEVEL environment variable instead\n\tDebug bool `yaml:\"debug,omitempty\"`\n\n\t// Metrics Whether to expose metrics at /metrics\n\tMetrics bool `yaml:\"metrics,omitempty\"`\n\n\t// SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration\n\t// if the configuration file is updated while the application is running\n\tSkipInvalidConfigUpdate bool `yaml:\"skip-invalid-config-update,omitempty\"`\n\n\t// DisableMonitoringLock Whether to disable the monitoring lock\n\t// The monitoring lock is what prevents multiple endpoints from being processed at the same time.\n\t// Disabling this may lead to inaccurate response times\n\t//\n\t// Deprecated: Use Concurrency instead TODO: REMOVE THIS IN v6.0.0\n\tDisableMonitoringLock bool `yaml:\"disable-monitoring-lock,omitempty\"`\n\n\t// Concurrency is the maximum number of endpoints/suites that can be monitored concurrently\n\t// Defaults to DefaultConcurrency. Set to 0 for unlimited concurrency.\n\tConcurrency int `yaml:\"concurrency,omitempty\"`\n\n\t// Security is the configuration for securing access to Gatus\n\tSecurity *security.Config `yaml:\"security,omitempty\"`\n\n\t// Alerting is the configuration for alerting providers\n\tAlerting *alerting.Config `yaml:\"alerting,omitempty\"`\n\n\t// Endpoints is the list of endpoints to monitor\n\tEndpoints []*endpoint.Endpoint `yaml:\"endpoints,omitempty\"`\n\n\t// ExternalEndpoints is the list of all external endpoints\n\tExternalEndpoints []*endpoint.ExternalEndpoint `yaml:\"external-endpoints,omitempty\"`\n\n\t// Suites is the list of suites to monitor\n\tSuites []*suite.Suite `yaml:\"suites,omitempty\"`\n\n\t// Storage is the configuration for how the data is stored\n\tStorage *storage.Config `yaml:\"storage,omitempty\"`\n\n\t// Web is the web configuration for the application\n\tWeb *web.Config `yaml:\"web,omitempty\"`\n\n\t// UI is the configuration for the UI\n\tUI *ui.Config `yaml:\"ui,omitempty\"`\n\n\t// Maintenance is the configuration for creating a maintenance window in which no alerts are sent\n\tMaintenance *maintenance.Config `yaml:\"maintenance,omitempty\"`\n\n\t// Remote is the configuration for remote Gatus instances\n\t// WARNING: This is in ALPHA and may change or be completely removed in the future\n\tRemote *remote.Config `yaml:\"remote,omitempty\"`\n\n\t// Connectivity is the configuration for connectivity\n\tConnectivity *connectivity.Config `yaml:\"connectivity,omitempty\"`\n\n\t// Tunneling is the configuration for SSH tunneling\n\tTunneling *tunneling.Config `yaml:\"tunneling,omitempty\"`\n\n\t// Announcements is the list of system-wide announcements\n\tAnnouncements []*announcement.Announcement `yaml:\"announcements,omitempty\"`\n\n\tconfigPath      string    // path to the file or directory from which config was loaded\n\tlastFileModTime time.Time // last modification time\n}\n\n// GetUniqueExtraMetricLabels returns a slice of unique metric labels from all enabled endpoints\n// in the configuration. It iterates through each endpoint, checks if it is enabled,\n// and then collects unique labels from the endpoint's labels map.\nfunc (config *Config) GetUniqueExtraMetricLabels() []string {\n\tlabels := make([]string, 0)\n\tfor _, ep := range config.Endpoints {\n\t\tif !ep.IsEnabled() {\n\t\t\tcontinue\n\t\t}\n\t\tfor label := range ep.ExtraLabels {\n\t\t\tif slices.Contains(labels, label) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlabels = append(labels, label)\n\t\t}\n\t}\n\tif len(labels) > 1 {\n\t\tsort.Strings(labels)\n\t}\n\treturn labels\n}\n\nfunc (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {\n\tfor i := 0; i < len(config.Endpoints); i++ {\n\t\tep := config.Endpoints[i]\n\t\tif ep.Key() == strings.ToLower(key) {\n\t\t\treturn ep\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (config *Config) GetExternalEndpointByKey(key string) *endpoint.ExternalEndpoint {\n\tfor i := 0; i < len(config.ExternalEndpoints); i++ {\n\t\tee := config.ExternalEndpoints[i]\n\t\tif ee.Key() == strings.ToLower(key) {\n\t\t\treturn ee\n\t\t}\n\t}\n\treturn nil\n}\n\n// HasLoadedConfigurationBeenModified returns whether one of the file that the\n// configuration has been loaded from has been modified since it was last read\nfunc (config *Config) HasLoadedConfigurationBeenModified() bool {\n\tlastMod := config.lastFileModTime.Unix()\n\tfileInfo, err := os.Stat(config.configPath)\n\tif err != nil {\n\t\treturn false\n\t}\n\tif fileInfo.IsDir() {\n\t\terr = walkConfigDir(config.configPath, func(path string, d fs.DirEntry, err error) error {\n\t\t\tif info, err := d.Info(); err == nil && lastMod < info.ModTime().Unix() {\n\t\t\t\treturn errEarlyReturn\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t\treturn errors.Is(err, errEarlyReturn)\n\t}\n\treturn !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()\n}\n\n// UpdateLastFileModTime refreshes Config.lastFileModTime\nfunc (config *Config) UpdateLastFileModTime() {\n\tconfig.lastFileModTime = time.Now()\n}\n\n// LoadConfiguration loads the full configuration composed of the main configuration file\n// and all composed configuration files\nfunc LoadConfiguration(configPath string) (*Config, error) {\n\tvar configBytes []byte\n\tvar fileInfo os.FileInfo\n\tvar usedConfigPath string\n\t// Figure out what config path we'll use (either configPath or the default config path)\n\tfor _, configurationPath := range []string{configPath, DefaultConfigurationFilePath, DefaultFallbackConfigurationFilePath} {\n\t\tif len(configurationPath) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tvar err error\n\t\tfileInfo, err = os.Stat(configurationPath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tusedConfigPath = configurationPath\n\t\tbreak\n\t}\n\tif len(usedConfigPath) == 0 {\n\t\treturn nil, ErrConfigFileNotFound\n\t}\n\tvar config *Config\n\tif fileInfo.IsDir() {\n\t\terr := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error walking path %s: %w\", path, err)\n\t\t\t}\n\t\t\tif strings.Contains(path, \"..\") {\n\t\t\t\tlogr.Warnf(\"[config.LoadConfiguration] Ignoring configuration from %s\", path)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tlogr.Infof(\"[config.LoadConfiguration] Reading configuration from %s\", path)\n\t\t\tdata, err := os.ReadFile(path)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error reading configuration from file %s: %w\", path, err)\n\t\t\t}\n\t\t\tconfigBytes, err = deepmerge.YAML(configBytes, data)\n\t\t\treturn err\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error reading configuration from directory %s: %w\", usedConfigPath, err)\n\t\t}\n\t} else {\n\t\tlogr.Infof(\"[config.LoadConfiguration] Reading configuration from configFile=%s\", usedConfigPath)\n\t\tif data, err := os.ReadFile(usedConfigPath); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error reading configuration from directory %s: %w\", usedConfigPath, err)\n\t\t} else {\n\t\t\tconfigBytes = data\n\t\t}\n\t}\n\tif len(configBytes) == 0 {\n\t\treturn nil, ErrConfigFileNotFound\n\t}\n\tconfig, err := parseAndValidateConfigBytes(configBytes)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error parsing config: %w\", err)\n\t}\n\tconfig.configPath = usedConfigPath\n\tconfig.UpdateLastFileModTime()\n\treturn config, nil\n}\n\n// walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files\nfunc walkConfigDir(path string, fn fs.WalkDirFunc) error {\n\tif len(path) == 0 {\n\t\t// If the user didn't provide a directory, we'll just use the default config file, so we can return nil now.\n\t\treturn nil\n\t}\n\treturn filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\tif d == nil || d.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\text := filepath.Ext(path)\n\t\tif ext != \".yml\" && ext != \".yaml\" {\n\t\t\treturn nil\n\t\t}\n\t\treturn fn(path, d, err)\n\t})\n}\n\n// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters\nfunc parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {\n\t// Replace $$ with __GATUS_LITERAL_DOLLAR_SIGN__ to prevent os.ExpandEnv from treating \"$$\" as if it was an\n\t// environment variable. This allows Gatus to support literal \"$\" in the configuration file.\n\tyamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), \"$$\", \"__GATUS_LITERAL_DOLLAR_SIGN__\"))\n\t// Expand environment variables\n\tyamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))\n\t// Replace __GATUS_LITERAL_DOLLAR_SIGN__ with \"$\" to restore the literal \"$\" in the configuration file\n\tyamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), \"__GATUS_LITERAL_DOLLAR_SIGN__\", \"$\"))\n\t// Parse configuration file\n\tif err = yaml.Unmarshal(yamlBytes, &config); err != nil {\n\t\treturn\n\t}\n\t// Check if the configuration file at least has endpoints configured\n\tif config == nil || (len(config.Endpoints) == 0 && len(config.Suites) == 0) {\n\t\terr = ErrNoEndpointOrSuiteInConfig\n\t} else {\n\t\t// XXX: Remove this in v6.0.0\n\t\tif config.Debug {\n\t\t\tlogr.Warn(\"WARNING: The 'debug' configuration has been deprecated and will be removed in v6.0.0\")\n\t\t\tlogr.Warn(\"WARNING: Please use the GATUS_LOG_LEVEL environment variable instead\")\n\t\t}\n\t\t// XXX: End of v6.0.0 removals\n\t\tValidateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)\n\t\tif err := ValidateSecurityConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateEndpointsConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateWebConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateUIConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateMaintenanceConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateStorageConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateRemoteConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateConnectivityConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateTunnelingConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateAnnouncementsConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateSuitesConfig(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := ValidateUniqueKeys(config); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tValidateAndSetConcurrencyDefaults(config)\n\t\t// Cross-config changes\n\t\tconfig.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults\n\t}\n\treturn\n}\n\nfunc ValidateConnectivityConfig(config *Config) error {\n\tif config.Connectivity != nil {\n\t\treturn config.Connectivity.ValidateAndSetDefaults()\n\t}\n\treturn nil\n}\n\n// ValidateTunnelingConfig validates the tunneling configuration and resolves tunnel references\n// NOTE: This must be called after ValidateEndpointsConfig and ValidateSuitesConfig\n// because it resolves tunnel references in endpoint and suite client configurations\nfunc ValidateTunnelingConfig(config *Config) error {\n\tif config.Tunneling != nil {\n\t\tif err := config.Tunneling.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Resolve tunnel references in all endpoints\n\t\tfor _, ep := range config.Endpoints {\n\t\t\tif err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {\n\t\t\t\treturn fmt.Errorf(\"endpoint '%s': %w\", ep.Key(), err)\n\t\t\t}\n\t\t}\n\t\t// Resolve tunnel references in suite endpoints\n\t\tfor _, s := range config.Suites {\n\t\t\tfor _, ep := range s.Endpoints {\n\t\t\t\tif err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"suite '%s' endpoint '%s': %w\", s.Key(), ep.Key(), err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// TODO: Add tunnel support for alert providers when needed\n\t}\n\treturn nil\n}\n\n// resolveTunnelForClientConfig resolves tunnel references in a client configuration\nfunc resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) error {\n\tif clientConfig == nil || clientConfig.Tunnel == \"\" {\n\t\treturn nil\n\t}\n\t// Validate tunnel name\n\ttunnelName := strings.TrimSpace(clientConfig.Tunnel)\n\tif tunnelName == \"\" {\n\t\treturn fmt.Errorf(\"tunnel name cannot be empty\")\n\t}\n\tif config.Tunneling == nil {\n\t\treturn fmt.Errorf(\"tunnel '%s' referenced but no tunneling configuration defined\", tunnelName)\n\t}\n\t_, exists := config.Tunneling.Tunnels[tunnelName]\n\tif !exists {\n\t\treturn fmt.Errorf(\"tunnel '%s' not found in tunneling configuration\", tunnelName)\n\t}\n\t// Get or create the SSH tunnel instance and store it directly in client config\n\ttunnel, err := config.Tunneling.GetTunnel(tunnelName)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get tunnel '%s': %w\", tunnelName, err)\n\t}\n\tclientConfig.ResolvedTunnel = tunnel\n\treturn nil\n}\n\nfunc ValidateAnnouncementsConfig(config *Config) error {\n\tif config.Announcements != nil {\n\t\tif err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Sort announcements by timestamp (newest first) for API response\n\t\tannouncement.SortByTimestamp(config.Announcements)\n\t}\n\treturn nil\n}\n\nfunc ValidateRemoteConfig(config *Config) error {\n\tif config.Remote != nil {\n\t\tif err := config.Remote.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ValidateStorageConfig(config *Config) error {\n\tif config.Storage == nil {\n\t\tconfig.Storage = &storage.Config{\n\t\t\tType:                   storage.TypeMemory,\n\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t}\n\t} else {\n\t\tif err := config.Storage.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ValidateMaintenanceConfig(config *Config) error {\n\tif config.Maintenance == nil {\n\t\tconfig.Maintenance = maintenance.GetDefaultConfig()\n\t} else {\n\t\tif err := config.Maintenance.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ValidateUIConfig(config *Config) error {\n\tif config.UI == nil {\n\t\tconfig.UI = ui.GetDefaultConfig()\n\t} else {\n\t\tif err := config.UI.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ValidateWebConfig(config *Config) error {\n\tif config.Web == nil {\n\t\tconfig.Web = web.GetDefaultConfig()\n\t} else {\n\t\treturn config.Web.ValidateAndSetDefaults()\n\t}\n\treturn nil\n}\n\nfunc ValidateEndpointsConfig(config *Config) error {\n\tduplicateValidationMap := make(map[string]bool)\n\t// Validate endpoints\n\tfor _, ep := range config.Endpoints {\n\t\tlogr.Debugf(\"[config.ValidateEndpointsConfig] Validating endpoint with key %s\", ep.Key())\n\t\tif endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {\n\t\t\treturn fmt.Errorf(\"invalid endpoint %s: name and group combination must be unique\", ep.Key())\n\t\t} else {\n\t\t\tduplicateValidationMap[endpointKey] = true\n\t\t}\n\t\tif err := ep.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid endpoint %s: %w\", ep.Key(), err)\n\t\t}\n\t}\n\tlogr.Infof(\"[config.ValidateEndpointsConfig] Validated %d endpoints\", len(config.Endpoints))\n\t// Validate external endpoints\n\tfor _, ee := range config.ExternalEndpoints {\n\t\tlogr.Debugf(\"[config.ValidateEndpointsConfig] Validating external endpoint '%s'\", ee.Key())\n\t\tif endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {\n\t\t\treturn fmt.Errorf(\"invalid external endpoint %s: name and group combination must be unique\", ee.Key())\n\t\t} else {\n\t\t\tduplicateValidationMap[endpointKey] = true\n\t\t}\n\t\tif err := ee.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid external endpoint %s: %w\", ee.Key(), err)\n\t\t}\n\t}\n\tlogr.Infof(\"[config.ValidateEndpointsConfig] Validated %d external endpoints\", len(config.ExternalEndpoints))\n\treturn nil\n}\n\nfunc ValidateSuitesConfig(config *Config) error {\n\tif config.Suites == nil || len(config.Suites) == 0 {\n\t\tlogr.Info(\"[config.ValidateSuitesConfig] No suites configured\")\n\t\treturn nil\n\t}\n\tsuiteNames := make(map[string]bool)\n\tfor _, suite := range config.Suites {\n\t\t// Check for duplicate suite names\n\t\tif suiteNames[suite.Name] {\n\t\t\treturn fmt.Errorf(\"duplicate suite name: %s\", suite.Key())\n\t\t}\n\t\tsuiteNames[suite.Name] = true\n\t\t// Validate the suite configuration\n\t\tif err := suite.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid suite '%s': %w\", suite.Key(), err)\n\t\t}\n\t\t// Check that endpoints referenced in Store mappings use valid placeholders\n\t\tfor _, suiteEndpoint := range suite.Endpoints {\n\t\t\tif suiteEndpoint.Store != nil {\n\t\t\t\tfor contextKey, placeholder := range suiteEndpoint.Store {\n\t\t\t\t\t// Basic validation that the context key is a valid identifier\n\t\t\t\t\tif len(contextKey) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"suite '%s' endpoint '%s' has empty context key in store mapping\", suite.Key(), suiteEndpoint.Key())\n\t\t\t\t\t}\n\t\t\t\t\tif len(placeholder) == 0 {\n\t\t\t\t\t\treturn fmt.Errorf(\"suite '%s' endpoint '%s' has empty placeholder in store mapping for key '%s'\", suite.Key(), suiteEndpoint.Key(), contextKey)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tlogr.Infof(\"[config.ValidateSuitesConfig] Validated %d suite(s)\", len(config.Suites))\n\treturn nil\n}\n\nfunc ValidateUniqueKeys(config *Config) error {\n\tkeyMap := make(map[string]string) // key -> description for error messages\n\t// Check all endpoints\n\tfor _, ep := range config.Endpoints {\n\t\tepKey := ep.Key()\n\t\tif existing, exists := keyMap[epKey]; exists {\n\t\t\treturn fmt.Errorf(\"duplicate key '%s': endpoint '%s' conflicts with %s\", epKey, ep.Key(), existing)\n\t\t}\n\t\tkeyMap[epKey] = fmt.Sprintf(\"endpoint '%s'\", ep.Key())\n\t}\n\t// Check all external endpoints\n\tfor _, ee := range config.ExternalEndpoints {\n\t\teeKey := ee.Key()\n\t\tif existing, exists := keyMap[eeKey]; exists {\n\t\t\treturn fmt.Errorf(\"duplicate key '%s': external endpoint '%s' conflicts with %s\", eeKey, ee.Key(), existing)\n\t\t}\n\t\tkeyMap[eeKey] = fmt.Sprintf(\"external endpoint '%s'\", ee.Key())\n\t}\n\t// Check all suites\n\tfor _, suite := range config.Suites {\n\t\tsuiteKey := suite.Key()\n\t\tif existing, exists := keyMap[suiteKey]; exists {\n\t\t\treturn fmt.Errorf(\"duplicate key '%s': suite '%s' conflicts with %s\", suiteKey, suite.Key(), existing)\n\t\t}\n\t\tkeyMap[suiteKey] = fmt.Sprintf(\"suite '%s'\", suite.Key())\n\t\t// Check endpoints within suites (they generate keys using suite group + endpoint name)\n\t\tfor _, ep := range suite.Endpoints {\n\t\t\tepKey := key.ConvertGroupAndNameToKey(suite.Group, ep.Name)\n\t\t\tif existing, exists := keyMap[epKey]; exists {\n\t\t\t\treturn fmt.Errorf(\"duplicate key '%s': endpoint '%s' in suite '%s' conflicts with %s\", epKey, epKey, suite.Key(), existing)\n\t\t\t}\n\t\t\tkeyMap[epKey] = fmt.Sprintf(\"endpoint '%s' in suite '%s'\", epKey, suite.Key())\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ValidateSecurityConfig(config *Config) error {\n\tif config.Security != nil {\n\t\tif !config.Security.ValidateAndSetDefaults() {\n\t\t\tlogr.Debug(\"[config.ValidateSecurityConfig] Basic security configuration has been validated\")\n\t\t\treturn ErrInvalidSecurityConfig\n\t\t}\n\t}\n\treturn nil\n}\n\n// ValidateAlertingConfig validates the alerting configuration\n// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert\n// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()\n// sets the default alert values when none are set.\nfunc ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {\n\tif alertingConfig == nil {\n\t\tlogr.Info(\"[config.ValidateAlertingConfig] Alerting is not configured\")\n\t\treturn\n\t}\n\talertTypes := []alert.Type{\n\t\talert.TypeAWSSES,\n\t\talert.TypeClickUp,\n\t\talert.TypeCustom,\n\t\talert.TypeDatadog,\n\t\talert.TypeDiscord,\n\t\talert.TypeEmail,\n\t\talert.TypeGitHub,\n\t\talert.TypeGitLab,\n\t\talert.TypeGitea,\n\t\talert.TypeGoogleChat,\n\t\talert.TypeGotify,\n\t\talert.TypeHomeAssistant,\n\t\talert.TypeIFTTT,\n\t\talert.TypeIlert,\n\t\talert.TypeIncidentIO,\n\t\talert.TypeLine,\n\t\talert.TypeMatrix,\n\t\talert.TypeMattermost,\n\t\talert.TypeMessagebird,\n\t\talert.TypeN8N,\n\t\talert.TypeNewRelic,\n\t\talert.TypeNtfy,\n\t\talert.TypeOpsgenie,\n\t\talert.TypePagerDuty,\n\t\talert.TypePlivo,\n\t\talert.TypePushover,\n\t\talert.TypeRocketChat,\n\t\talert.TypeSendGrid,\n\t\talert.TypeSignal,\n\t\talert.TypeSIGNL4,\n\t\talert.TypeSlack,\n\t\talert.TypeSplunk,\n\t\talert.TypeSquadcast,\n\t\talert.TypeTeams,\n\t\talert.TypeTeamsWorkflows,\n\t\talert.TypeTelegram,\n\t\talert.TypeTwilio,\n\t\talert.TypeVonage,\n\t\talert.TypeWebex,\n\t\talert.TypeZapier,\n\t\talert.TypeZulip,\n\t}\n\tvar validProviders, invalidProviders []alert.Type\n\tfor _, alertType := range alertTypes {\n\t\talertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)\n\t\tif alertProvider != nil {\n\t\t\tif err := alertProvider.Validate(); err == nil {\n\t\t\t\t// Parse alerts with the provider's default alert\n\t\t\t\tif alertProvider.GetDefaultAlert() != nil {\n\t\t\t\t\tfor _, ep := range endpoints {\n\t\t\t\t\t\tfor alertIndex, endpointAlert := range ep.Alerts {\n\t\t\t\t\t\t\tif alertType == endpointAlert.Type {\n\t\t\t\t\t\t\t\tlogr.Debugf(\"[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s\", alertIndex, alertType, ep.Key())\n\t\t\t\t\t\t\t\tprovider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)\n\t\t\t\t\t\t\t\t// Validate the endpoint alert's overrides, if applicable\n\t\t\t\t\t\t\t\tif len(endpointAlert.ProviderOverride) > 0 {\n\t\t\t\t\t\t\t\t\tif err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {\n\t\t\t\t\t\t\t\t\t\tlogr.Warnf(\"[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s\", ep.Key(), alertType, err.Error())\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tfor _, ee := range externalEndpoints {\n\t\t\t\t\t\tfor alertIndex, endpointAlert := range ee.Alerts {\n\t\t\t\t\t\t\tif alertType == endpointAlert.Type {\n\t\t\t\t\t\t\t\tlogr.Debugf(\"[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s\", alertIndex, alertType, ee.Key())\n\t\t\t\t\t\t\t\tprovider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)\n\t\t\t\t\t\t\t\t// Validate the endpoint alert's overrides, if applicable\n\t\t\t\t\t\t\t\tif len(endpointAlert.ProviderOverride) > 0 {\n\t\t\t\t\t\t\t\t\tif err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {\n\t\t\t\t\t\t\t\t\t\tlogr.Warnf(\"[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s\", ee.Key(), alertType, err.Error())\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tvalidProviders = append(validProviders, alertType)\n\t\t\t} else {\n\t\t\t\tlogr.Warnf(\"[config.ValidateAlertingConfig] Ignoring provider=%s due to error=%s\", alertType, err.Error())\n\t\t\t\tinvalidProviders = append(invalidProviders, alertType)\n\t\t\t\talertingConfig.SetAlertingProviderToNil(alertProvider)\n\t\t\t}\n\t\t} else {\n\t\t\tinvalidProviders = append(invalidProviders, alertType)\n\t\t}\n\t}\n\tlogr.Infof(\"[config.ValidateAlertingConfig] configuredProviders=%s; ignoredProviders=%s\", validProviders, invalidProviders)\n}\n\nfunc ValidateAndSetConcurrencyDefaults(config *Config) {\n\tif config.DisableMonitoringLock {\n\t\tconfig.Concurrency = 0\n\t\tlogr.Warn(\"WARNING: The 'disable-monitoring-lock' configuration has been deprecated and will be removed in v6.0.0\")\n\t\tlogr.Warn(\"WARNING: Please set 'concurrency: 0' instead\")\n\t\tlogr.Debug(\"[config.ValidateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency\")\n\t} else if config.Concurrency <= 0 && !config.DisableMonitoringLock {\n\t\tconfig.Concurrency = DefaultConcurrency\n\t\tlogr.Debugf(\"[config.ValidateAndSetConcurrencyDefaults] Setting default concurrency to %d\", config.Concurrency)\n\t} else {\n\t\tlogr.Debugf(\"[config.ValidateAndSetConcurrencyDefaults] Using configured concurrency of %d\", config.Concurrency)\n\t}\n}\n"
  },
  {
    "path": "config/config_test.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting\"\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/awsses\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/clickup\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/custom\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/datadog\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/discord\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/email\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/gitea\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/github\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/gitlab\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/googlechat\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/gotify\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/homeassistant\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ifttt\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ilert\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/incidentio\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/line\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/matrix\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/mattermost\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/messagebird\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/newrelic\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ntfy\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/opsgenie\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/pagerduty\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/plivo\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/pushover\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/rocketchat\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/sendgrid\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/signal\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/signl4\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/slack\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/splunk\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/squadcast\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/teams\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/telegram\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/twilio\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/vonage\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/webex\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/zapier\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/zulip\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/config/tunneling\"\n\t\"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel\"\n\t\"github.com/TwiN/gatus/v5/config/web\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"gopkg.in/yaml.v3\"\n)\n\nfunc TestLoadConfiguration(t *testing.T) {\n\tyes := true\n\tdir := t.TempDir()\n\tscenarios := []struct {\n\t\tname           string\n\t\tconfigPath     string            // value to pass as the configPath parameter in LoadConfiguration\n\t\tpathAndFiles   map[string]string // files to create in dir\n\t\texpectedConfig *Config\n\t\texpectedError  error\n\t}{\n\t\t{\n\t\t\tname:       \"empty-config-file\",\n\t\t\tconfigPath: filepath.Join(dir, \"config.yaml\"),\n\t\t\tpathAndFiles: map[string]string{\n\t\t\t\t\"config.yaml\": \"\",\n\t\t\t},\n\t\t\texpectedError: ErrConfigFileNotFound,\n\t\t},\n\t\t{\n\t\t\tname:          \"config-file-that-does-not-exist\",\n\t\t\tconfigPath:    filepath.Join(dir, \"config.yaml\"),\n\t\t\texpectedError: ErrConfigFileNotFound,\n\t\t},\n\t\t{\n\t\t\tname:       \"config-file-with-endpoint-that-has-no-url\",\n\t\t\tconfigPath: filepath.Join(dir, \"config.yaml\"),\n\t\t\tpathAndFiles: map[string]string{\n\t\t\t\t\"config.yaml\": `\nendpoints:\n  - name: website`,\n\t\t\t},\n\t\t\texpectedError: endpoint.ErrEndpointWithNoURL,\n\t\t},\n\t\t{\n\t\t\tname:       \"config-file-with-endpoint-that-has-no-conditions\",\n\t\t\tconfigPath: filepath.Join(dir, \"config.yaml\"),\n\t\t\tpathAndFiles: map[string]string{\n\t\t\t\t\"config.yaml\": `\nendpoints:\n  - name: website\n    url: https://twin.sh/health`,\n\t\t\t},\n\t\t\texpectedError: endpoint.ErrEndpointWithNoCondition,\n\t\t},\n\t\t{\n\t\t\tname:       \"config-file\",\n\t\t\tconfigPath: filepath.Join(dir, \"config.yaml\"),\n\t\t\tpathAndFiles: map[string]string{\n\t\t\t\t\"config.yaml\": `\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"`,\n\t\t\t},\n\t\t\texpectedConfig: &Config{\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"website\",\n\t\t\t\t\t\tURL:        \"https://twin.sh/health\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\"[STATUS] == 200\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:          \"empty-dir\",\n\t\t\tconfigPath:    dir,\n\t\t\tpathAndFiles:  map[string]string{},\n\t\t\texpectedError: ErrConfigFileNotFound,\n\t\t},\n\t\t{\n\t\t\tname:       \"dir-with-empty-config-file\",\n\t\t\tconfigPath: dir,\n\t\t\tpathAndFiles: map[string]string{\n\t\t\t\t\"config.yaml\": \"\",\n\t\t\t},\n\t\t\texpectedError: ErrNoEndpointOrSuiteInConfig,\n\t\t},\n\t\t{\n\t\t\tname:       \"dir-with-two-config-files\",\n\t\t\tconfigPath: dir,\n\t\t\tpathAndFiles: map[string]string{\n\t\t\t\t\"config.yaml\": `endpoints:\n  - name: one\n    url: https://example.com\n    conditions:\n      - \"[CONNECTED] == true\"\n      - \"[STATUS] == 200\"\n\n  - name: two\n    url: https://example.org\n    conditions:\n      - \"len([BODY]) > 0\"`,\n\t\t\t\t\"config.yml\": `endpoints:\n  - name: three\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"`,\n\t\t\t},\n\t\t\texpectedConfig: &Config{\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"one\",\n\t\t\t\t\t\tURL:        \"https://example.com\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\"[CONNECTED] == true\", \"[STATUS] == 200\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"two\",\n\t\t\t\t\t\tURL:        \"https://example.org\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\"len([BODY]) > 0\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"three\",\n\t\t\t\t\t\tURL:        \"https://twin.sh/health\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\"[STATUS] == 200\", \"[BODY].status == UP\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:       \"dir-with-2-config-files-deep-merge-with-map-slice-and-primitive\",\n\t\t\tconfigPath: dir,\n\t\t\tpathAndFiles: map[string]string{\n\t\t\t\t\"a.yaml\": `\nmetrics: true\n\nalerting:\n  slack:\n    webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz\n    default-alert:\n      enabled: true\n\nendpoints:\n  - name: example\n    url: https://example.org\n    interval: 5s\n    conditions:\n      - \"[STATUS] == 200\"`,\n\t\t\t\t\"b.yaml\": `\n\nalerting:\n  discord:\n    webhook-url: https://discord.com/api/webhooks/xxx/yyy\n\nexternal-endpoints:\n  - name: ext-ep-test\n    token: \"potato\"\n    alerts:\n      - type: slack\n\nendpoints:\n  - name: frontend\n    url: https://example.com\n    conditions:\n      - \"[STATUS] == 200\"`,\n\t\t\t},\n\t\t\texpectedConfig: &Config{\n\t\t\t\tMetrics: true,\n\t\t\t\tAlerting: &alerting.Config{\n\t\t\t\t\tDiscord: &discord.AlertProvider{DefaultConfig: discord.Config{WebhookURL: \"https://discord.com/api/webhooks/xxx/yyy\"}},\n\t\t\t\t\tSlack:   &slack.AlertProvider{DefaultConfig: slack.Config{WebhookURL: \"https://hooks.slack.com/services/xxx/yyy/zzz\"}, DefaultAlert: &alert.Alert{Enabled: &yes}},\n\t\t\t\t},\n\t\t\t\tExternalEndpoints: []*endpoint.ExternalEndpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"ext-ep-test\",\n\t\t\t\t\t\tToken: \"potato\",\n\t\t\t\t\t\tAlerts: []*alert.Alert{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:             alert.TypeSlack,\n\t\t\t\t\t\t\t\tFailureThreshold: 3,\n\t\t\t\t\t\t\t\tSuccessThreshold: 2,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"example\",\n\t\t\t\t\t\tURL:        \"https://example.org\",\n\t\t\t\t\t\tInterval:   5 * time.Second,\n\t\t\t\t\t\tConditions: []endpoint.Condition{\"[STATUS] == 200\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"frontend\",\n\t\t\t\t\t\tURL:        \"https://example.com\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\"[STATUS] == 200\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tfor path, content := range scenario.pathAndFiles {\n\t\t\t\tif err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil {\n\t\t\t\t\tt.Fatalf(\"[%s] failed to write file: %v\", scenario.name, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tdefer func(pathAndFiles map[string]string) {\n\t\t\t\tfor path := range pathAndFiles {\n\t\t\t\t\t_ = os.Remove(filepath.Join(dir, path))\n\t\t\t\t}\n\t\t\t}(scenario.pathAndFiles)\n\t\t\tconfig, err := LoadConfiguration(scenario.configPath)\n\t\t\tif !errors.Is(err, scenario.expectedError) {\n\t\t\t\tt.Errorf(\"[%s] expected error %v, got %v\", scenario.name, scenario.expectedError, err)\n\t\t\t\treturn\n\t\t\t} else if err != nil && errors.Is(err, scenario.expectedError) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// parse the expected output so that expectations are closer to reality (under the right circumstances, even I can be poetic)\n\t\t\texpectedConfigAsYAML, _ := yaml.Marshal(scenario.expectedConfig)\n\t\t\texpectedConfigAfterBeingParsedAndValidated, err := parseAndValidateConfigBytes(expectedConfigAsYAML)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"[%s] failed to parse expected config: %v\", scenario.name, err)\n\t\t\t}\n\t\t\t// Marshal em' before comparing em' so that we don't have to deal with formatting and ordering\n\t\t\tactualConfigAsYAML, err := yaml.Marshal(config)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"[%s] failed to marshal actual config: %v\", scenario.name, err)\n\t\t\t}\n\t\t\texpectedConfigAfterBeingParsedAndValidatedAsYAML, _ := yaml.Marshal(expectedConfigAfterBeingParsedAndValidated)\n\t\t\tif string(actualConfigAsYAML) != string(expectedConfigAfterBeingParsedAndValidatedAsYAML) {\n\t\t\t\tt.Errorf(\"[%s] expected config %s, got %s\", scenario.name, string(expectedConfigAfterBeingParsedAndValidatedAsYAML), string(actualConfigAsYAML))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {\n\tt.Parallel()\n\tdir := t.TempDir()\n\n\tconfigFilePath := filepath.Join(dir, \"config.yaml\")\n\t_ = os.WriteFile(configFilePath, []byte(`endpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n`), 0o644)\n\n\tt.Run(\"config-file-as-config-path\", func(t *testing.T) {\n\t\tconfig, err := LoadConfiguration(configFilePath)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to load configuration: %v\", err)\n\t\t}\n\t\tif config.HasLoadedConfigurationBeenModified() {\n\t\t\tt.Errorf(\"expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created\")\n\t\t}\n\t\ttime.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second\n\t\t// Update the config file\n\t\tif err = os.WriteFile(filepath.Join(dir, \"config.yaml\"), []byte(`endpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"`), 0o644); err != nil {\n\t\t\tt.Fatalf(\"failed to overwrite config file: %v\", err)\n\t\t}\n\t\tif !config.HasLoadedConfigurationBeenModified() {\n\t\t\tt.Errorf(\"expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory\")\n\t\t}\n\t})\n\tt.Run(\"config-directory-as-config-path\", func(t *testing.T) {\n\t\tconfig, err := LoadConfiguration(dir)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to load configuration: %v\", err)\n\t\t}\n\t\tif config.HasLoadedConfigurationBeenModified() {\n\t\t\tt.Errorf(\"expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created\")\n\t\t}\n\t\ttime.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second\n\t\t// Update the config file\n\t\tif err = os.WriteFile(filepath.Join(dir, \"metrics.yaml\"), []byte(`metrics: true`), 0o644); err != nil {\n\t\t\tt.Fatalf(\"failed to overwrite config file: %v\", err)\n\t\t}\n\t\tif !config.HasLoadedConfigurationBeenModified() {\n\t\t\tt.Errorf(\"expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory\")\n\t\t}\n\t})\n}\n\nfunc TestParseAndValidateConfigBytes(t *testing.T) {\n\tfile := t.TempDir() + \"/test.db\"\n\tconfig, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`\nstorage:\n  type: sqlite\n  path: %s\n  maximum-number-of-results: 10\n  maximum-number-of-events: 5\n\nmaintenance:\n  enabled: true\n  start: 00:00\n  duration: 4h\n  every: [Monday, Thursday]\n\nui:\n  title: T\n  header: H\n  link: https://example.org\n  buttons:\n    - name: \"Home\"\n      link: \"https://example.org\"\n    - name: \"Status page\"\n      link: \"https://status.example.org\"\n\nexternal-endpoints:\n  - name: ext-ep-test\n    group: core\n    token: \"potato\"\n\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    interval: 15s\n    conditions:\n      - \"[STATUS] == 200\"\n\n  - name: github\n    url: https://api.github.com/healthz\n    client:\n      insecure: true\n      ignore-redirect: true\n      timeout: 5s\n    conditions:\n      - \"[STATUS] != 400\"\n      - \"[STATUS] != 500\"\n\n  - name: example\n    url: https://example.com/\n    interval: 30m\n    client:\n      insecure: true\n    conditions:\n      - \"[STATUS] == 200\"\n`, file)))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {\n\t\tt.Error(\"expected storage to be set to sqlite, got\", config.Storage)\n\t}\n\tif config.Storage == nil || config.Storage.MaximumNumberOfResults != 10 || config.Storage.MaximumNumberOfEvents != 5 {\n\t\tt.Error(\"expected MaximumNumberOfResults and MaximumNumberOfEvents to be set to 10 and 5, got\", config.Storage.MaximumNumberOfResults, config.Storage.MaximumNumberOfEvents)\n\t}\n\tif 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\" {\n\t\tt.Error(\"expected ui to be set to T, H, https://example.org, 2 buttons, Home and Status page, got\", config.UI)\n\t}\n\tif mc := config.Maintenance; mc == nil || mc.Start != \"00:00\" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 {\n\t\tt.Error(\"Expected Config.Maintenance to be configured properly\")\n\t}\n\tif len(config.ExternalEndpoints) != 1 {\n\t\tt.Error(\"Should have returned one external endpoint\")\n\t}\n\tif config.ExternalEndpoints[0].Name != \"ext-ep-test\" {\n\t\tt.Errorf(\"Name should have been %s\", \"ext-ep-test\")\n\t}\n\tif config.ExternalEndpoints[0].Group != \"core\" {\n\t\tt.Errorf(\"Group should have been %s\", \"core\")\n\t}\n\tif config.ExternalEndpoints[0].Token != \"potato\" {\n\t\tt.Errorf(\"Token should have been %s\", \"potato\")\n\t}\n\n\tif len(config.Endpoints) != 3 {\n\t\tt.Error(\"Should have returned two endpoints\")\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/health\")\n\t}\n\tif config.Endpoints[0].Method != \"GET\" {\n\t\tt.Errorf(\"Method should have been %s (default)\", \"GET\")\n\t}\n\tif config.Endpoints[0].Interval != 15*time.Second {\n\t\tt.Errorf(\"Interval should have been %s\", 15*time.Second)\n\t}\n\tif config.Endpoints[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure {\n\t\tt.Errorf(\"ClientConfig.Insecure should have been %v, got %v\", true, config.Endpoints[0].ClientConfig.Insecure)\n\t}\n\tif config.Endpoints[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {\n\t\tt.Errorf(\"ClientConfig.IgnoreRedirect should have been %v, got %v\", true, config.Endpoints[0].ClientConfig.IgnoreRedirect)\n\t}\n\tif config.Endpoints[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout {\n\t\tt.Errorf(\"ClientConfig.Timeout should have been %v, got %v\", client.GetDefaultConfig().Timeout, config.Endpoints[0].ClientConfig.Timeout)\n\t}\n\tif len(config.Endpoints[0].Conditions) != 1 {\n\t\tt.Errorf(\"There should have been %d conditions\", 1)\n\t}\n\tif config.Endpoints[1].URL != \"https://api.github.com/healthz\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://api.github.com/healthz\")\n\t}\n\tif config.Endpoints[1].Method != \"GET\" {\n\t\tt.Errorf(\"Method should have been %s (default)\", \"GET\")\n\t}\n\tif config.Endpoints[1].Interval != 60*time.Second {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 60*time.Second)\n\t}\n\tif !config.Endpoints[1].ClientConfig.Insecure {\n\t\tt.Errorf(\"ClientConfig.Insecure should have been %v, got %v\", true, config.Endpoints[1].ClientConfig.Insecure)\n\t}\n\tif !config.Endpoints[1].ClientConfig.IgnoreRedirect {\n\t\tt.Errorf(\"ClientConfig.IgnoreRedirect should have been %v, got %v\", true, config.Endpoints[1].ClientConfig.IgnoreRedirect)\n\t}\n\tif config.Endpoints[1].ClientConfig.Timeout != 5*time.Second {\n\t\tt.Errorf(\"ClientConfig.Timeout should have been %v, got %v\", 5*time.Second, config.Endpoints[1].ClientConfig.Timeout)\n\t}\n\tif len(config.Endpoints[1].Conditions) != 2 {\n\t\tt.Errorf(\"There should have been %d conditions\", 2)\n\t}\n\tif config.Endpoints[2].URL != \"https://example.com/\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://example.com/\")\n\t}\n\tif config.Endpoints[2].Method != \"GET\" {\n\t\tt.Errorf(\"Method should have been %s (default)\", \"GET\")\n\t}\n\tif config.Endpoints[2].Interval != 30*time.Minute {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 30*time.Minute)\n\t}\n\tif !config.Endpoints[2].ClientConfig.Insecure {\n\t\tt.Errorf(\"ClientConfig.Insecure should have been %v, got %v\", true, config.Endpoints[2].ClientConfig.Insecure)\n\t}\n\tif config.Endpoints[2].ClientConfig.IgnoreRedirect {\n\t\tt.Errorf(\"ClientConfig.IgnoreRedirect should have been %v by default, got %v\", false, config.Endpoints[2].ClientConfig.IgnoreRedirect)\n\t}\n\tif config.Endpoints[2].ClientConfig.Timeout != 10*time.Second {\n\t\tt.Errorf(\"ClientConfig.Timeout should have been %v by default, got %v\", 10*time.Second, config.Endpoints[2].ClientConfig.Timeout)\n\t}\n\tif len(config.Endpoints[2].Conditions) != 1 {\n\t\tt.Errorf(\"There should have been %d conditions\", 1)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesDefault(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"DefaultConfig shouldn't have been nil\")\n\t}\n\tif config.Metrics {\n\t\tt.Error(\"Metrics should've been false by default\")\n\t}\n\tif config.Web.Address != web.DefaultAddress {\n\t\tt.Errorf(\"Bind address should have been %s, because it is the default value\", web.DefaultAddress)\n\t}\n\tif config.Web.Port != web.DefaultPort {\n\t\tt.Errorf(\"Port should have been %d, because it is the default value\", web.DefaultPort)\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/health\")\n\t}\n\tif config.Endpoints[0].Interval != 60*time.Second {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 60*time.Second)\n\t}\n\tif config.Endpoints[0].ClientConfig.Insecure != client.GetDefaultConfig().Insecure {\n\t\tt.Errorf(\"ClientConfig.Insecure should have been %v by default, got %v\", true, config.Endpoints[0].ClientConfig.Insecure)\n\t}\n\tif config.Endpoints[0].ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {\n\t\tt.Errorf(\"ClientConfig.IgnoreRedirect should have been %v by default, got %v\", true, config.Endpoints[0].ClientConfig.IgnoreRedirect)\n\t}\n\tif config.Endpoints[0].ClientConfig.Timeout != client.GetDefaultConfig().Timeout {\n\t\tt.Errorf(\"ClientConfig.Timeout should have been %v by default, got %v\", client.GetDefaultConfig().Timeout, config.Endpoints[0].ClientConfig.Timeout)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithAddress(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nweb:\n  address: 127.0.0.1\nendpoints:\n  - name: website\n    url: https://twin.sh/actuator/health\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif config.Metrics {\n\t\tt.Error(\"Metrics should've been false by default\")\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/actuator/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/actuator/health\")\n\t}\n\tif config.Endpoints[0].Interval != 60*time.Second {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 60*time.Second)\n\t}\n\tif config.Web.Address != \"127.0.0.1\" {\n\t\tt.Errorf(\"Bind address should have been %s, because it is specified in config\", \"127.0.0.1\")\n\t}\n\tif config.Web.Port != web.DefaultPort {\n\t\tt.Errorf(\"Port should have been %d, because it is the default value\", web.DefaultPort)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithPort(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nweb:\n  port: 12345\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif config.Metrics {\n\t\tt.Error(\"Metrics should've been false by default\")\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/health\")\n\t}\n\tif config.Endpoints[0].Interval != 60*time.Second {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 60*time.Second)\n\t}\n\tif config.Web.Address != web.DefaultAddress {\n\t\tt.Errorf(\"Bind address should have been %s, because it is the default value\", web.DefaultAddress)\n\t}\n\tif config.Web.Port != 12345 {\n\t\tt.Errorf(\"Port should have been %d, because it is specified in config\", 12345)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithPortAndHost(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nweb:\n  port: 12345\n  address: 127.0.0.1\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif config.Metrics {\n\t\tt.Error(\"Metrics should've been false by default\")\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/health\")\n\t}\n\tif config.Endpoints[0].Interval != 60*time.Second {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 60*time.Second)\n\t}\n\tif config.Web.Address != \"127.0.0.1\" {\n\t\tt.Errorf(\"Bind address should have been %s, because it is specified in config\", \"127.0.0.1\")\n\t}\n\tif config.Web.Port != 12345 {\n\t\tt.Errorf(\"Port should have been %d, because it is specified in config\", 12345)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithInvalidPort(t *testing.T) {\n\t_, err := parseAndValidateConfigBytes([]byte(`\nweb:\n  port: 65536\n  address: 127.0.0.1\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err == nil {\n\t\tt.Fatal(\"Should've returned an error because the configuration specifies an invalid port value\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithMetricsAndCustomUserAgentHeader(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nmetrics: true\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    headers:\n      User-Agent: Test/2.0\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif !config.Metrics {\n\t\tt.Error(\"Metrics should have been true\")\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/health\")\n\t}\n\tif config.Endpoints[0].Interval != 60*time.Second {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 60*time.Second)\n\t}\n\tif config.Web.Address != web.DefaultAddress {\n\t\tt.Errorf(\"Bind address should have been %s, because it is the default value\", web.DefaultAddress)\n\t}\n\tif config.Web.Port != web.DefaultPort {\n\t\tt.Errorf(\"Port should have been %d, because it is the default value\", web.DefaultPort)\n\t}\n\tif userAgent := config.Endpoints[0].Headers[\"User-Agent\"]; userAgent != \"Test/2.0\" {\n\t\tt.Errorf(\"User-Agent should've been %s, got %s\", \"Test/2.0\", userAgent)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithMetricsAndHostAndPort(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nmetrics: true\nweb:\n  address: 192.168.0.1\n  port: 9090\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif !config.Metrics {\n\t\tt.Error(\"Metrics should have been true\")\n\t}\n\tif config.Web.Address != \"192.168.0.1\" {\n\t\tt.Errorf(\"Bind address should have been %s, because it is the default value\", \"192.168.0.1\")\n\t}\n\tif config.Web.Port != 9090 {\n\t\tt.Errorf(\"Port should have been %d, because it is specified in config\", 9090)\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/health\")\n\t}\n\tif config.Endpoints[0].Interval != 60*time.Second {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 60*time.Second)\n\t}\n\tif userAgent := config.Endpoints[0].Headers[\"User-Agent\"]; userAgent != endpoint.GatusUserAgent {\n\t\tt.Errorf(\"User-Agent should've been %s because it's the default value, got %s\", endpoint.GatusUserAgent, userAgent)\n\t}\n}\n\nfunc TestParseAndValidateBadConfigBytes(t *testing.T) {\n\t_, err := parseAndValidateConfigBytes([]byte(`\nbadconfig:\n  - asdsa: w0w\n    usadasdrl: asdxzczxc\n    asdas:\n      - soup\n`))\n\tif err == nil {\n\t\tt.Error(\"An error should've been returned\")\n\t}\n\tif err != ErrNoEndpointOrSuiteInConfig {\n\t\tt.Error(\"The error returned should have been of type ErrNoEndpointOrSuiteInConfig\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithAlerting(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nalerting:\n  slack:\n    webhook-url: \"http://example.com\"\n  discord:\n    webhook-url: \"http://example.org\"\n  pagerduty:\n    integration-key: \"00000000000000000000000000000000\"\n  pushover:\n    application-token: \"000000000000000000000000000000\"\n    user-key: \"000000000000000000000000000000\"\n  mattermost:\n    webhook-url: \"http://example.com\"\n    client:\n      insecure: true\n  messagebird:\n    access-key: \"1\"\n    originator: \"31619191918\"\n    recipients: \"31619191919\"\n  telegram:\n    token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\n    id: 0123456789\n  twilio:\n    sid: \"1234\"\n    token: \"5678\"\n    from: \"+1-234-567-8901\"\n    to: \"+1-234-567-8901\"\n  teams:\n    webhook-url: \"http://example.com\"\n\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    alerts:\n      - type: slack\n      - type: pagerduty\n        failure-threshold: 7\n        success-threshold: 5\n        description: \"Healthcheck failed 7 times in a row\"\n      - type: mattermost\n      - type: messagebird\n        enabled: false\n      - type: discord\n        failure-threshold: 10\n      - type: telegram\n        enabled: true\n      - type: twilio\n        failure-threshold: 12\n        success-threshold: 15\n      - type: teams\n      - type: pushover\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\t// Alerting providers\n\tif config.Alerting == nil {\n\t\tt.Fatal(\"config.Alerting shouldn't have been nil\")\n\t}\n\tif config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {\n\t\tt.Fatal(\"Slack alerting config should've been valid\")\n\t}\n\t// Endpoints\n\tif len(config.Endpoints) != 1 {\n\t\tt.Error(\"There should've been 1 endpoint\")\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/health\")\n\t}\n\tif config.Endpoints[0].Interval != 60*time.Second {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 60*time.Second)\n\t}\n\tif len(config.Endpoints[0].Alerts) != 9 {\n\t\tt.Fatal(\"There should've been 9 alerts configured\")\n\t}\n\n\tif config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeSlack, config.Endpoints[0].Alerts[0].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[0].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[0].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[0].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[0].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[0].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[1].Type != alert.TypePagerDuty {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypePagerDuty, config.Endpoints[0].Alerts[1].Type)\n\t}\n\tif config.Endpoints[0].Alerts[1].GetDescription() != \"Healthcheck failed 7 times in a row\" {\n\t\tt.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())\n\t}\n\tif config.Endpoints[0].Alerts[1].FailureThreshold != 7 {\n\t\tt.Errorf(\"The failure threshold of the alert should've been %d, but it was %d\", 7, config.Endpoints[0].Alerts[1].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[1].SuccessThreshold != 5 {\n\t\tt.Errorf(\"The success threshold of the alert should've been %d, but it was %d\", 5, config.Endpoints[0].Alerts[1].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[2].Type != alert.TypeMattermost {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeMattermost, config.Endpoints[0].Alerts[2].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[2].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[2].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[2].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[2].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[2].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[3].Type != alert.TypeMessagebird {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeMessagebird, config.Endpoints[0].Alerts[3].Type)\n\t}\n\tif config.Endpoints[0].Alerts[3].IsEnabled() {\n\t\tt.Error(\"The alert should've been disabled\")\n\t}\n\n\tif config.Endpoints[0].Alerts[4].Type != alert.TypeDiscord {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeDiscord, config.Endpoints[0].Alerts[4].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[4].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[4].FailureThreshold != 10 {\n\t\tt.Errorf(\"The failure threshold of the alert should've been %d, but it was %d\", 10, config.Endpoints[0].Alerts[4].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[4].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[4].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeTelegram, config.Endpoints[0].Alerts[5].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[5].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[5].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[5].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[5].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[5].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[6].Type != alert.TypeTwilio {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeTwilio, config.Endpoints[0].Alerts[6].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[6].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[6].FailureThreshold != 12 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 12, config.Endpoints[0].Alerts[6].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[6].SuccessThreshold != 15 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 15, config.Endpoints[0].Alerts[6].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[7].Type != alert.TypeTeams {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeTeams, config.Endpoints[0].Alerts[7].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[7].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[7].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[7].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[7].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[7].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[8].Type != alert.TypePushover {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypePushover, config.Endpoints[0].Alerts[8].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[8].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nalerting:\n  slack:\n    webhook-url: \"http://example.com\"\n    default-alert:\n      enabled: true\n  discord:\n    webhook-url: \"http://example.org\"\n    default-alert:\n      enabled: true\n      failure-threshold: 10\n      success-threshold: 15\n  pagerduty:\n    integration-key: \"00000000000000000000000000000000\"\n    default-alert:\n      enabled: true\n      description: default description\n      failure-threshold: 7\n      success-threshold: 5\n  pushover:\n    application-token: \"000000000000000000000000000000\"\n    user-key: \"000000000000000000000000000000\"\n    default-alert:\n      enabled: true\n      description: default description\n      failure-threshold: 5\n      success-threshold: 3\n  mattermost:\n    webhook-url: \"http://example.com\"\n    default-alert:\n      enabled: true\n  messagebird:\n    access-key: \"1\"\n    originator: \"31619191918\"\n    recipients: \"31619191919\"\n    default-alert:\n      enabled: false\n      send-on-resolved: true\n  telegram:\n    token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\n    id: 0123456789\n    default-alert:\n      enabled: true\n  twilio:\n    sid: \"1234\"\n    token: \"5678\"\n    from: \"+1-234-567-8901\"\n    to: \"+1-234-567-8901\"\n    default-alert:\n      enabled: true\n      failure-threshold: 12\n      success-threshold: 15\n  teams:\n    webhook-url: \"http://example.com\"\n    default-alert:\n      enabled: true\n  email:\n    from: \"from@example.com\"\n    username: \"from@example.com\"\n    password: \"hunter2\"\n    host: \"mail.example.com\"\n    port: 587\n    to: \"recipient1@example.com,recipient2@example.com\"\n    client:\n      insecure: false\n    default-alert:\n      enabled: true\n  gotify:\n    server-url: \"https://gotify.example\"\n    token: \"**************\"\n    default-alert:\n      enabled: true\n\nexternal-endpoints:\n  - name: ext-ep-test\n    group: core\n    token: potato\n    alerts:\n      - type: discord\n\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    alerts:\n      - type: slack\n      - type: pagerduty\n      - type: mattermost\n      - type: messagebird\n      - type: discord\n        success-threshold: 8 # test endpoint alert override\n      - type: telegram\n      - type: twilio\n      - type: teams\n      - type: pushover\n      - type: email\n      - type: gotify\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif config.Metrics {\n\t\tt.Error(\"Metrics should've been false by default\")\n\t}\n\t// Alerting providers\n\tif config.Alerting == nil {\n\t\tt.Fatal(\"config.Alerting shouldn't have been nil\")\n\t}\n\n\tif config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {\n\t\tt.Fatal(\"Slack alerting config should've been valid\")\n\t}\n\tif config.Alerting.Slack.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Slack.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\tif config.Alerting.Slack.DefaultConfig.WebhookURL != \"http://example.com\" {\n\t\tt.Errorf(\"Slack webhook should've been %s, but was %s\", \"http://example.com\", config.Alerting.Slack.DefaultConfig.WebhookURL)\n\t}\n\n\tif config.Alerting.PagerDuty == nil || config.Alerting.PagerDuty.Validate() != nil {\n\t\tt.Fatal(\"PagerDuty alerting config should've been valid\")\n\t}\n\tif config.Alerting.PagerDuty.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"PagerDuty.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\tif config.Alerting.PagerDuty.DefaultConfig.IntegrationKey != \"00000000000000000000000000000000\" {\n\t\tt.Errorf(\"PagerDuty integration key should've been %s, but was %s\", \"00000000000000000000000000000000\", config.Alerting.PagerDuty.DefaultConfig.IntegrationKey)\n\t}\n\n\tif config.Alerting.Pushover == nil || config.Alerting.Pushover.Validate() != nil {\n\t\tt.Fatal(\"Pushover alerting config should've been valid\")\n\t}\n\tif config.Alerting.Pushover.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Pushover.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\tif config.Alerting.Pushover.DefaultConfig.ApplicationToken != \"000000000000000000000000000000\" {\n\t\tt.Errorf(\"Pushover application token should've been %s, but was %s\", \"000000000000000000000000000000\", config.Alerting.Pushover.DefaultConfig.ApplicationToken)\n\t}\n\tif config.Alerting.Pushover.DefaultConfig.UserKey != \"000000000000000000000000000000\" {\n\t\tt.Errorf(\"Pushover user key should've been %s, but was %s\", \"000000000000000000000000000000\", config.Alerting.Pushover.DefaultConfig.UserKey)\n\t}\n\n\tif config.Alerting.Mattermost == nil || config.Alerting.Mattermost.Validate() != nil {\n\t\tt.Fatal(\"Mattermost alerting config should've been valid\")\n\t}\n\tif config.Alerting.Mattermost.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Mattermost.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\n\tif config.Alerting.Messagebird == nil || config.Alerting.Messagebird.Validate() != nil {\n\t\tt.Fatal(\"Messagebird alerting config should've been valid\")\n\t}\n\tif config.Alerting.Messagebird.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Messagebird.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\tif config.Alerting.Messagebird.DefaultConfig.AccessKey != \"1\" {\n\t\tt.Errorf(\"Messagebird access key should've been %s, but was %s\", \"1\", config.Alerting.Messagebird.DefaultConfig.AccessKey)\n\t}\n\tif config.Alerting.Messagebird.DefaultConfig.Originator != \"31619191918\" {\n\t\tt.Errorf(\"Messagebird originator field should've been %s, but was %s\", \"31619191918\", config.Alerting.Messagebird.DefaultConfig.Originator)\n\t}\n\tif config.Alerting.Messagebird.DefaultConfig.Recipients != \"31619191919\" {\n\t\tt.Errorf(\"Messagebird to recipients should've been %s, but was %s\", \"31619191919\", config.Alerting.Messagebird.DefaultConfig.Recipients)\n\t}\n\n\tif config.Alerting.Discord == nil || config.Alerting.Discord.Validate() != nil {\n\t\tt.Fatal(\"Discord alerting config should've been valid\")\n\t}\n\tif config.Alerting.Discord.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Discord.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\tif config.Alerting.Discord.GetDefaultAlert().FailureThreshold != 10 {\n\t\tt.Errorf(\"Discord default alert failure threshold should've been %d, but was %d\", 10, config.Alerting.Discord.GetDefaultAlert().FailureThreshold)\n\t}\n\tif config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 {\n\t\tt.Errorf(\"Discord default alert success threshold should've been %d, but was %d\", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold)\n\t}\n\tif config.Alerting.Discord.DefaultConfig.WebhookURL != \"http://example.org\" {\n\t\tt.Errorf(\"Discord webhook should've been %s, but was %s\", \"http://example.org\", config.Alerting.Discord.DefaultConfig.WebhookURL)\n\t}\n\tif config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord {\n\t\tt.Error(\"expected discord configuration\")\n\t}\n\n\tif config.Alerting.Telegram == nil || config.Alerting.Telegram.Validate() != nil {\n\t\tt.Fatal(\"Telegram alerting config should've been valid\")\n\t}\n\tif config.Alerting.Telegram.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Telegram.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\tif config.Alerting.Telegram.DefaultConfig.Token != \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\" {\n\t\tt.Errorf(\"Telegram token should've been %s, but was %s\", \"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\", config.Alerting.Telegram.DefaultConfig.Token)\n\t}\n\tif config.Alerting.Telegram.DefaultConfig.ID != \"0123456789\" {\n\t\tt.Errorf(\"Telegram ID should've been %s, but was %s\", \"012345689\", config.Alerting.Telegram.DefaultConfig.ID)\n\t}\n\n\tif config.Alerting.Twilio == nil || config.Alerting.Twilio.Validate() != nil {\n\t\tt.Fatal(\"Twilio alerting config should've been valid\")\n\t}\n\tif config.Alerting.Twilio.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Twilio.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\n\tif config.Alerting.Teams == nil || config.Alerting.Teams.Validate() != nil {\n\t\tt.Fatal(\"Teams alerting config should've been valid\")\n\t}\n\tif config.Alerting.Teams.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Teams.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\n\tif config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {\n\t\tt.Fatal(\"Email alerting config should've been valid\")\n\t}\n\tif config.Alerting.Email.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Email.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\tif config.Alerting.Email.DefaultConfig.From != \"from@example.com\" {\n\t\tt.Errorf(\"Email from should've been %s, but was %s\", \"from@example.com\", config.Alerting.Email.DefaultConfig.From)\n\t}\n\tif config.Alerting.Email.DefaultConfig.Username != \"from@example.com\" {\n\t\tt.Errorf(\"Email username should've been %s, but was %s\", \"from@example.com\", config.Alerting.Email.DefaultConfig.Username)\n\t}\n\tif config.Alerting.Email.DefaultConfig.Password != \"hunter2\" {\n\t\tt.Errorf(\"Email password should've been %s, but was %s\", \"hunter2\", config.Alerting.Email.DefaultConfig.Password)\n\t}\n\tif config.Alerting.Email.DefaultConfig.Host != \"mail.example.com\" {\n\t\tt.Errorf(\"Email host should've been %s, but was %s\", \"mail.example.com\", config.Alerting.Email.DefaultConfig.Host)\n\t}\n\tif config.Alerting.Email.DefaultConfig.Port != 587 {\n\t\tt.Errorf(\"Email port should've been %d, but was %d\", 587, config.Alerting.Email.DefaultConfig.Port)\n\t}\n\tif config.Alerting.Email.DefaultConfig.To != \"recipient1@example.com,recipient2@example.com\" {\n\t\tt.Errorf(\"Email to should've been %s, but was %s\", \"recipient1@example.com,recipient2@example.com\", config.Alerting.Email.DefaultConfig.To)\n\t}\n\tif config.Alerting.Email.DefaultConfig.ClientConfig == nil {\n\t\tt.Fatal(\"Email client config should've been set\")\n\t}\n\tif config.Alerting.Email.DefaultConfig.ClientConfig.Insecure {\n\t\tt.Error(\"Email client config should've been secure\")\n\t}\n\n\tif config.Alerting.Gotify == nil || config.Alerting.Gotify.Validate() != nil {\n\t\tt.Fatal(\"Gotify alerting config should've been valid\")\n\t}\n\tif config.Alerting.Gotify.GetDefaultAlert() == nil {\n\t\tt.Fatal(\"Gotify.GetDefaultAlert() shouldn't have returned nil\")\n\t}\n\tif config.Alerting.Gotify.DefaultConfig.ServerURL != \"https://gotify.example\" {\n\t\tt.Errorf(\"Gotify server URL should've been %s, but was %s\", \"https://gotify.example\", config.Alerting.Gotify.DefaultConfig.ServerURL)\n\t}\n\tif config.Alerting.Gotify.DefaultConfig.Token != \"**************\" {\n\t\tt.Errorf(\"Gotify token should've been %s, but was %s\", \"**************\", config.Alerting.Gotify.DefaultConfig.Token)\n\t}\n\n\t// External endpoints\n\tif len(config.ExternalEndpoints) != 1 {\n\t\tt.Error(\"There should've been 1 external endpoint\")\n\t}\n\tif config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeDiscord, config.ExternalEndpoints[0].Alerts[0].Type)\n\t}\n\tif !config.ExternalEndpoints[0].Alerts[0].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 10 {\n\t\tt.Errorf(\"The failure threshold of the alert should've been %d, but it was %d\", 10, config.ExternalEndpoints[0].Alerts[0].FailureThreshold)\n\t}\n\tif config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 15 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 15, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold)\n\t}\n\n\t// Endpoints\n\tif len(config.Endpoints) != 1 {\n\t\tt.Error(\"There should've been 1 endpoint\")\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/health\")\n\t}\n\tif config.Endpoints[0].Interval != 60*time.Second {\n\t\tt.Errorf(\"Interval should have been %s, because it is the default value\", 60*time.Second)\n\t}\n\tif len(config.Endpoints[0].Alerts) != 11 {\n\t\tt.Fatalf(\"There should've been 11 alerts configured, got %d\", len(config.Endpoints[0].Alerts))\n\t}\n\n\tif config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeSlack, config.Endpoints[0].Alerts[0].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[0].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[0].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[0].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[0].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[0].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[1].Type != alert.TypePagerDuty {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypePagerDuty, config.Endpoints[0].Alerts[1].Type)\n\t}\n\tif config.Endpoints[0].Alerts[1].GetDescription() != \"default description\" {\n\t\tt.Errorf(\"The description of the alert should've been %s, but it was %s\", \"default description\", config.Endpoints[0].Alerts[1].GetDescription())\n\t}\n\tif config.Endpoints[0].Alerts[1].FailureThreshold != 7 {\n\t\tt.Errorf(\"The failure threshold of the alert should've been %d, but it was %d\", 7, config.Endpoints[0].Alerts[1].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[1].SuccessThreshold != 5 {\n\t\tt.Errorf(\"The success threshold of the alert should've been %d, but it was %d\", 5, config.Endpoints[0].Alerts[1].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[2].Type != alert.TypeMattermost {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeMattermost, config.Endpoints[0].Alerts[2].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[2].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[2].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[2].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[2].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[2].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[3].Type != alert.TypeMessagebird {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeMessagebird, config.Endpoints[0].Alerts[3].Type)\n\t}\n\tif config.Endpoints[0].Alerts[3].IsEnabled() {\n\t\tt.Error(\"The alert should've been disabled\")\n\t}\n\tif !config.Endpoints[0].Alerts[3].IsSendingOnResolved() {\n\t\tt.Error(\"The alert should be sending on resolve\")\n\t}\n\n\tif config.Endpoints[0].Alerts[4].Type != alert.TypeDiscord {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeDiscord, config.Endpoints[0].Alerts[4].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[4].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[4].FailureThreshold != 10 {\n\t\tt.Errorf(\"The failure threshold of the alert should've been %d, but it was %d\", 10, config.Endpoints[0].Alerts[4].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[4].SuccessThreshold != 8 {\n\t\tt.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)\n\t}\n\n\tif config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeTelegram, config.Endpoints[0].Alerts[5].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[5].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[5].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[5].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[5].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[5].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[6].Type != alert.TypeTwilio {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeTwilio, config.Endpoints[0].Alerts[6].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[6].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[6].FailureThreshold != 12 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 12, config.Endpoints[0].Alerts[6].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[6].SuccessThreshold != 15 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 15, config.Endpoints[0].Alerts[6].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[7].Type != alert.TypeTeams {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeTeams, config.Endpoints[0].Alerts[7].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[7].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[7].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[7].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[7].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[7].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[8].Type != alert.TypePushover {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypePushover, config.Endpoints[0].Alerts[8].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[8].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[8].FailureThreshold != 5 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[8].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[8].SuccessThreshold != 3 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[9].Type != alert.TypeEmail {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeEmail, config.Endpoints[0].Alerts[9].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[9].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[9].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[9].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[9].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[9].SuccessThreshold)\n\t}\n\n\tif config.Endpoints[0].Alerts[10].Type != alert.TypeGotify {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeGotify, config.Endpoints[0].Alerts[10].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[10].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[10].FailureThreshold != 3 {\n\t\tt.Errorf(\"The default failure threshold of the alert should've been %d, but it was %d\", 3, config.Endpoints[0].Alerts[10].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[10].SuccessThreshold != 2 {\n\t\tt.Errorf(\"The default success threshold of the alert should've been %d, but it was %d\", 2, config.Endpoints[0].Alerts[10].SuccessThreshold)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nalerting:\n  slack:\n    webhook-url: \"https://example.com\"\n    default-alert:\n      enabled: true\n      description: \"description\"\n\nendpoints:\n - name: website\n   url: https://twin.sh/health\n   alerts:\n     - type: slack\n       failure-threshold: 10\n     - type: slack\n       failure-threshold: 20\n       description: \"wow\"\n     - type: slack\n       enabled: false\n       failure-threshold: 30\n       provider-override:\n         webhook-url: https://example.com\n   conditions:\n     - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\t// Alerting providers\n\tif config.Alerting == nil {\n\t\tt.Fatal(\"config.Alerting shouldn't have been nil\")\n\t}\n\tif config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {\n\t\tt.Fatal(\"Slack alerting config should've been valid\")\n\t}\n\t// Endpoints\n\tif len(config.Endpoints) != 1 {\n\t\tt.Error(\"There should've been 2 endpoints\")\n\t}\n\tif config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeSlack, config.Endpoints[0].Alerts[0].Type)\n\t}\n\tif config.Endpoints[0].Alerts[1].Type != alert.TypeSlack {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeSlack, config.Endpoints[0].Alerts[1].Type)\n\t}\n\tif config.Endpoints[0].Alerts[2].Type != alert.TypeSlack {\n\t\tt.Errorf(\"The type of the alert should've been %s, but it was %s\", alert.TypeSlack, config.Endpoints[0].Alerts[2].Type)\n\t}\n\tif !config.Endpoints[0].Alerts[0].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif !config.Endpoints[0].Alerts[1].IsEnabled() {\n\t\tt.Error(\"The alert should've been enabled\")\n\t}\n\tif config.Endpoints[0].Alerts[2].IsEnabled() {\n\t\tt.Error(\"The alert should've been disabled\")\n\t}\n\tif config.Endpoints[0].Alerts[0].GetDescription() != \"description\" {\n\t\tt.Errorf(\"The description of the alert should've been %s, but it was %s\", \"description\", config.Endpoints[0].Alerts[0].GetDescription())\n\t}\n\tif config.Endpoints[0].Alerts[1].GetDescription() != \"wow\" {\n\t\tt.Errorf(\"The description of the alert should've been %s, but it was %s\", \"description\", config.Endpoints[0].Alerts[1].GetDescription())\n\t}\n\tif config.Endpoints[0].Alerts[2].GetDescription() != \"description\" {\n\t\tt.Errorf(\"The description of the alert should've been %s, but it was %s\", \"description\", config.Endpoints[0].Alerts[2].GetDescription())\n\t}\n\tif config.Endpoints[0].Alerts[0].FailureThreshold != 10 {\n\t\tt.Errorf(\"The failure threshold of the alert should've been %d, but it was %d\", 10, config.Endpoints[0].Alerts[0].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[1].FailureThreshold != 20 {\n\t\tt.Errorf(\"The failure threshold of the alert should've been %d, but it was %d\", 20, config.Endpoints[0].Alerts[1].FailureThreshold)\n\t}\n\tif config.Endpoints[0].Alerts[2].FailureThreshold != 30 {\n\t\tt.Errorf(\"The failure threshold of the alert should've been %d, but it was %d\", 30, config.Endpoints[0].Alerts[2].FailureThreshold)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithInvalidPagerDutyAlertingConfig(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nalerting:\n  pagerduty:\n    integration-key: \"INVALID_KEY\"\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    alerts:\n      - type: pagerduty\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif config.Alerting == nil {\n\t\tt.Fatal(\"config.Alerting shouldn't have been nil\")\n\t}\n\tif config.Alerting.PagerDuty != nil {\n\t\tt.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\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithInvalidPushoverAlertingConfig(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nalerting:\n  pushover:\n    application-token: \"INVALID_TOKEN\"\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    alerts:\n      - type: pushover\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif config.Alerting == nil {\n\t\tt.Fatal(\"config.Alerting shouldn't have been nil\")\n\t}\n\tif config.Alerting.Pushover != nil {\n\t\tt.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\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithCustomAlertingConfig(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nalerting:\n  custom:\n    url: \"https://example.com\"\n    body: |\n      {\n        \"text\": \"[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]\"\n      }\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    alerts:\n      - type: custom\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"Config shouldn't have been nil\")\n\t}\n\tif config.Alerting == nil {\n\t\tt.Fatal(\"config.Alerting shouldn't have been nil\")\n\t}\n\tif config.Alerting.Custom == nil {\n\t\tt.Fatal(\"Custom alerting config shouldn't have been nil\")\n\t}\n\tif err = config.Alerting.Custom.Validate(); err != nil {\n\t\tt.Fatal(\"Custom alerting config should've been valid\")\n\t}\n\tcfg, _ := config.Alerting.Custom.GetConfig(\"\", &alert.Alert{ProviderOverride: map[string]any{\"client\": map[string]any{\"insecure\": true}}})\n\tif config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != \"RESOLVED\" {\n\t\tt.Fatal(\"ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got\", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true))\n\t}\n\tif config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != \"TRIGGERED\" {\n\t\tt.Fatal(\"ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got\", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false))\n\t}\n\tif !cfg.ClientConfig.Insecure {\n\t\tt.Errorf(\"ClientConfig.Insecure should have been %v, got %v\", true, cfg.ClientConfig.Insecure)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithCustomAlertingConfigAndCustomPlaceholderValues(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nalerting:\n  custom:\n    placeholders:\n      ALERT_TRIGGERED_OR_RESOLVED:\n        TRIGGERED: \"partial_outage\"\n        RESOLVED: \"operational\"\n    url: \"https://example.com\"\n    insecure: true\n    body: \"[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]\"\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    alerts:\n      - type: custom\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"DefaultConfig shouldn't have been nil\")\n\t}\n\tif config.Alerting == nil {\n\t\tt.Fatal(\"config.Alerting shouldn't have been nil\")\n\t}\n\tif config.Alerting.Custom == nil {\n\t\tt.Fatal(\"Custom alerting config shouldn't have been nil\")\n\t}\n\tif err = config.Alerting.Custom.Validate(); err != nil {\n\t\tt.Fatal(\"Custom alerting config should've been valid\")\n\t}\n\tcfg, _ := config.Alerting.Custom.GetConfig(\"\", &alert.Alert{})\n\tif config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != \"operational\" {\n\t\tt.Fatal(\"ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'\")\n\t}\n\tif config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != \"partial_outage\" {\n\t\tt.Fatal(\"ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithCustomAlertingConfigAndOneCustomPlaceholderValue(t *testing.T) {\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nalerting:\n  custom:\n    placeholders:\n      ALERT_TRIGGERED_OR_RESOLVED:\n        TRIGGERED: \"partial_outage\"\n    url: \"https://example.com\"\n    body: \"[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_NAME] - [ALERT_DESCRIPTION]\"\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    alerts:\n      - type: custom\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"DefaultConfig shouldn't have been nil\")\n\t}\n\tif config.Alerting == nil {\n\t\tt.Fatal(\"config.Alerting shouldn't have been nil\")\n\t}\n\tif config.Alerting.Custom == nil {\n\t\tt.Fatal(\"Custom alerting config shouldn't have been nil\")\n\t}\n\tif err := config.Alerting.Custom.Validate(); err != nil {\n\t\tt.Fatal(\"Custom alerting config should've been valid\")\n\t}\n\tcfg, _ := config.Alerting.Custom.GetConfig(\"\", &alert.Alert{})\n\tif config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != \"RESOLVED\" {\n\t\tt.Fatal(\"ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'\")\n\t}\n\tif config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != \"partial_outage\" {\n\t\tt.Fatal(\"ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithInvalidEndpointName(t *testing.T) {\n\t_, err := parseAndValidateConfigBytes([]byte(`\nendpoints:\n  - name: \"\"\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err == nil {\n\t\tt.Error(\"should've returned an error\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithDuplicateEndpointName(t *testing.T) {\n\tscenarios := []struct {\n\t\tname        string\n\t\tshouldError bool\n\t\tconfig      string\n\t}{\n\t\t{\n\t\t\tname:        \"same-name-no-group\",\n\t\t\tshouldError: true,\n\t\t\tconfig: `\nendpoints:\n  - name: ep1\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n  - name: ep1\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"same-name-different-group\",\n\t\t\tshouldError: false,\n\t\t\tconfig: `\nendpoints:\n  - name: ep1\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n  - name: ep1\n    group: g1\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"same-name-same-group\",\n\t\t\tshouldError: true,\n\t\t\tconfig: `\nendpoints:\n  - name: ep1\n    group: g1\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n  - name: ep1\n    group: g1\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"same-name-different-endpoint-type\",\n\t\t\tshouldError: true,\n\t\t\tconfig: `\nexternal-endpoints:\n  - name: ep1\n    token: \"12345678\"\n\nendpoints:\n  - name: ep1\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"same-name-different-group-different-endpoint-type\",\n\t\t\tshouldError: false,\n\t\t\tconfig: `\nexternal-endpoints:\n  - name: ep1\n    group: gr1\n    token: \"12345678\"\n\nendpoints:\n  - name: ep1\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"`,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\t_, err := parseAndValidateConfigBytes([]byte(scenario.config))\n\t\t\tif scenario.shouldError && err == nil {\n\t\t\t\tt.Error(\"should've returned an error\")\n\t\t\t} else if !scenario.shouldError && err != nil {\n\t\t\t\tt.Error(\"shouldn't have returned an error\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) {\n\t_, err := parseAndValidateConfigBytes([]byte(`\nstorage:\n  type: sqlite\nendpoints:\n  - name: example\n    url: https://example.org\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err == nil {\n\t\tt.Error(\"should've returned an error, because a file must be specified for a storage of type sqlite\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithInvalidYAML(t *testing.T) {\n\t_, err := parseAndValidateConfigBytes([]byte(`\nstorage:\n  invalid yaml\nendpoints:\n  - name: example\n    url: https://example.org\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err == nil {\n\t\tt.Error(\"should've returned an error\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithInvalidSecurityConfig(t *testing.T) {\n\t_, err := parseAndValidateConfigBytes([]byte(`\nsecurity:\n  basic:\n    username: \"admin\"\n    password-sha512: \"invalid-sha512-hash\"\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n`))\n\tif err == nil {\n\t\tt.Error(\"should've returned an error\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {\n\tconst expectedUsername = \"admin\"\n\tconst expectedPasswordHash = \"JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu\"\n\tconfig, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`\nsecurity:\n  basic:\n    username: \"%s\"\n    password-bcrypt-base64: \"%s\"\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[STATUS] == 200\"\n`, expectedUsername, expectedPasswordHash)))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"DefaultConfig shouldn't have been nil\")\n\t}\n\tif config.Security == nil {\n\t\tt.Fatal(\"config.Security shouldn't have been nil\")\n\t}\n\tif !config.Security.ValidateAndSetDefaults() {\n\t\tt.Error(\"Security config should've been valid\")\n\t}\n\tif config.Security.Basic == nil {\n\t\tt.Fatal(\"config.Security.Basic shouldn't have been nil\")\n\t}\n\tif config.Security.Basic.Username != expectedUsername {\n\t\tt.Errorf(\"config.Security.Basic.Username should've been %s, but was %s\", expectedUsername, config.Security.Basic.Username)\n\t}\n\tif config.Security.Basic.PasswordBcryptHashBase64Encoded != expectedPasswordHash {\n\t\tt.Errorf(\"config.Security.Basic.PasswordBcryptHashBase64Encoded should've been %s, but was %s\", expectedPasswordHash, config.Security.Basic.PasswordBcryptHashBase64Encoded)\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithLiteralDollarSign(t *testing.T) {\n\tos.Setenv(\"GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign\", \"whatever\")\n\tconfig, err := parseAndValidateConfigBytes([]byte(`\nendpoints:\n  - name: website\n    url: https://twin.sh/health\n    conditions:\n      - \"[BODY] == $$GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign\"\n      - \"[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign\"\n`))\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif config == nil {\n\t\tt.Fatal(\"DefaultConfig shouldn't have been nil\")\n\t}\n\tif config.Endpoints[0].URL != \"https://twin.sh/health\" {\n\t\tt.Errorf(\"URL should have been %s\", \"https://twin.sh/health\")\n\t}\n\tif config.Endpoints[0].Conditions[0] != \"[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign\" {\n\t\tt.Errorf(\"Condition should have been %s\", \"[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign\")\n\t}\n\tif config.Endpoints[0].Conditions[1] != \"[BODY] == whatever\" {\n\t\tt.Errorf(\"Condition should have been %s\", \"[BODY] == whatever\")\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {\n\t_, err := parseAndValidateConfigBytes([]byte(``))\n\tif !errors.Is(err, ErrNoEndpointOrSuiteInConfig) {\n\t\tt.Error(\"The error returned should have been of type ErrNoEndpointOrSuiteInConfig\")\n\t}\n}\n\nfunc TestGetAlertingProviderByAlertType(t *testing.T) {\n\talertingConfig := &alerting.Config{\n\t\tAWSSimpleEmailService: &awsses.AlertProvider{},\n\t\tClickUp:               &clickup.AlertProvider{},\n\t\tCustom:                &custom.AlertProvider{},\n\t\tDatadog:               &datadog.AlertProvider{},\n\t\tDiscord:               &discord.AlertProvider{},\n\t\tEmail:                 &email.AlertProvider{},\n\t\tGitea:                 &gitea.AlertProvider{},\n\t\tGitHub:                &github.AlertProvider{},\n\t\tGitLab:                &gitlab.AlertProvider{},\n\t\tGoogleChat:            &googlechat.AlertProvider{},\n\t\tGotify:                &gotify.AlertProvider{},\n\t\tHomeAssistant:         &homeassistant.AlertProvider{},\n\t\tIFTTT:                 &ifttt.AlertProvider{},\n\t\tIlert:                 &ilert.AlertProvider{},\n\t\tIncidentIO:            &incidentio.AlertProvider{},\n\t\tLine:                  &line.AlertProvider{},\n\t\tMatrix:                &matrix.AlertProvider{},\n\t\tMattermost:            &mattermost.AlertProvider{},\n\t\tMessagebird:           &messagebird.AlertProvider{},\n\t\tNewRelic:              &newrelic.AlertProvider{},\n\t\tNtfy:                  &ntfy.AlertProvider{},\n\t\tOpsgenie:              &opsgenie.AlertProvider{},\n\t\tPagerDuty:             &pagerduty.AlertProvider{},\n\t\tPlivo:                 &plivo.AlertProvider{},\n\t\tPushover:              &pushover.AlertProvider{},\n\t\tRocketChat:            &rocketchat.AlertProvider{},\n\t\tSendGrid:              &sendgrid.AlertProvider{},\n\t\tSignal:                &signal.AlertProvider{},\n\t\tSIGNL4:                &signl4.AlertProvider{},\n\t\tSlack:                 &slack.AlertProvider{},\n\t\tSplunk:                &splunk.AlertProvider{},\n\t\tSquadcast:             &squadcast.AlertProvider{},\n\t\tTelegram:              &telegram.AlertProvider{},\n\t\tTeams:                 &teams.AlertProvider{},\n\t\tTeamsWorkflows:        &teamsworkflows.AlertProvider{},\n\t\tTwilio:                &twilio.AlertProvider{},\n\t\tVonage:                &vonage.AlertProvider{},\n\t\tWebex:                 &webex.AlertProvider{},\n\t\tZapier:                &zapier.AlertProvider{},\n\t\tZulip:                 &zulip.AlertProvider{},\n\t}\n\tscenarios := []struct {\n\t\talertType alert.Type\n\t\texpected  provider.AlertProvider\n\t}{\n\t\t{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},\n\t\t{alertType: alert.TypeClickUp, expected: alertingConfig.ClickUp},\n\t\t{alertType: alert.TypeCustom, expected: alertingConfig.Custom},\n\t\t{alertType: alert.TypeDatadog, expected: alertingConfig.Datadog},\n\t\t{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},\n\t\t{alertType: alert.TypeEmail, expected: alertingConfig.Email},\n\t\t{alertType: alert.TypeGitea, expected: alertingConfig.Gitea},\n\t\t{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},\n\t\t{alertType: alert.TypeGitLab, expected: alertingConfig.GitLab},\n\t\t{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},\n\t\t{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},\n\t\t{alertType: alert.TypeHomeAssistant, expected: alertingConfig.HomeAssistant},\n\t\t{alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},\n\t\t{alertType: alert.TypeIlert, expected: alertingConfig.Ilert},\n\t\t{alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},\n\t\t{alertType: alert.TypeLine, expected: alertingConfig.Line},\n\t\t{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},\n\t\t{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},\n\t\t{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},\n\t\t{alertType: alert.TypeNewRelic, expected: alertingConfig.NewRelic},\n\t\t{alertType: alert.TypeNtfy, expected: alertingConfig.Ntfy},\n\t\t{alertType: alert.TypeOpsgenie, expected: alertingConfig.Opsgenie},\n\t\t{alertType: alert.TypePagerDuty, expected: alertingConfig.PagerDuty},\n\t\t{alertType: alert.TypePlivo, expected: alertingConfig.Plivo},\n\t\t{alertType: alert.TypePushover, expected: alertingConfig.Pushover},\n\t\t{alertType: alert.TypeRocketChat, expected: alertingConfig.RocketChat},\n\t\t{alertType: alert.TypeSendGrid, expected: alertingConfig.SendGrid},\n\t\t{alertType: alert.TypeSignal, expected: alertingConfig.Signal},\n\t\t{alertType: alert.TypeSIGNL4, expected: alertingConfig.SIGNL4},\n\t\t{alertType: alert.TypeSlack, expected: alertingConfig.Slack},\n\t\t{alertType: alert.TypeSplunk, expected: alertingConfig.Splunk},\n\t\t{alertType: alert.TypeSquadcast, expected: alertingConfig.Squadcast},\n\t\t{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},\n\t\t{alertType: alert.TypeTeams, expected: alertingConfig.Teams},\n\t\t{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},\n\t\t{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},\n\t\t{alertType: alert.TypeVonage, expected: alertingConfig.Vonage},\n\t\t{alertType: alert.TypeWebex, expected: alertingConfig.Webex},\n\t\t{alertType: alert.TypeZapier, expected: alertingConfig.Zapier},\n\t\t{alertType: alert.TypeZulip, expected: alertingConfig.Zulip},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(string(scenario.alertType), func(t *testing.T) {\n\t\t\tif alertingConfig.GetAlertingProviderByAlertType(scenario.alertType) != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %s configuration\", scenario.alertType)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tconfig   *Config\n\t\texpected []string\n\t}{\n\t\t{\n\t\t\tname: \"no-endpoints\",\n\t\t\tconfig: &Config{\n\t\t\t\tEndpoints: []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\texpected: []string{},\n\t\t},\n\t\t{\n\t\t\tname: \"single-endpoint-no-labels\",\n\t\t\tconfig: &Config{\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"endpoint1\",\n\t\t\t\t\t\tURL:  \"https://example.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []string{},\n\t\t},\n\t\t{\n\t\t\tname: \"single-endpoint-with-labels\",\n\t\t\tconfig: &Config{\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"endpoint1\",\n\t\t\t\t\t\tURL:     \"https://example.com\",\n\t\t\t\t\t\tEnabled: toPtr(true),\n\t\t\t\t\t\tExtraLabels: map[string]string{\n\t\t\t\t\t\t\t\"env\":  \"production\",\n\t\t\t\t\t\t\t\"team\": \"backend\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []string{\"env\", \"team\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple-endpoints-with-labels\",\n\t\t\tconfig: &Config{\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"endpoint1\",\n\t\t\t\t\t\tURL:     \"https://example.com\",\n\t\t\t\t\t\tEnabled: toPtr(true),\n\t\t\t\t\t\tExtraLabels: map[string]string{\n\t\t\t\t\t\t\t\"env\":    \"production\",\n\t\t\t\t\t\t\t\"team\":   \"backend\",\n\t\t\t\t\t\t\t\"module\": \"auth\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"endpoint2\",\n\t\t\t\t\t\tURL:     \"https://example.org\",\n\t\t\t\t\t\tEnabled: toPtr(true),\n\t\t\t\t\t\tExtraLabels: map[string]string{\n\t\t\t\t\t\t\t\"env\":  \"staging\",\n\t\t\t\t\t\t\t\"team\": \"frontend\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []string{\"env\", \"team\", \"module\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple-endpoints-with-some-disabled\",\n\t\t\tconfig: &Config{\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"endpoint1\",\n\t\t\t\t\t\tURL:     \"https://example.com\",\n\t\t\t\t\t\tEnabled: toPtr(true),\n\t\t\t\t\t\tExtraLabels: map[string]string{\n\t\t\t\t\t\t\t\"env\":  \"production\",\n\t\t\t\t\t\t\t\"team\": \"backend\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:    \"endpoint2\",\n\t\t\t\t\t\tURL:     \"https://example.org\",\n\t\t\t\t\t\tEnabled: toPtr(false),\n\t\t\t\t\t\tExtraLabels: map[string]string{\n\t\t\t\t\t\t\t\"module\": \"auth\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []string{\"env\", \"team\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tlabels := tt.config.GetUniqueExtraMetricLabels()\n\t\t\tif len(labels) != len(tt.expected) {\n\t\t\t\tt.Errorf(\"expected %d labels, got %d\", len(tt.expected), len(labels))\n\t\t\t}\n\t\t\tfor _, label := range tt.expected {\n\t\t\t\tif !slices.Contains(labels, label) {\n\t\t\t\t\tt.Errorf(\"expected label %s to be present\", label)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithDuplicateKeysAcrossEntityTypes(t *testing.T) {\n\tscenarios := []struct {\n\t\tname        string\n\t\tshouldError bool\n\t\texpectedErr string\n\t\tconfig      string\n\t}{\n\t\t{\n\t\t\tname:        \"endpoint-suite-same-key\",\n\t\t\tshouldError: true,\n\t\t\texpectedErr: \"duplicate key 'backend_test-api': suite 'backend_test-api' conflicts with endpoint 'backend_test-api'\",\n\t\t\tconfig: `\nendpoints:\n  - name: test-api\n    group: backend\n    url: https://example.com/api\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: test-api\n    group: backend\n    interval: 30s\n    endpoints:\n      - name: step1\n        url: https://example.com/test\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"endpoint-suite-different-keys\",\n\t\t\tshouldError: false,\n\t\t\tconfig: `\nendpoints:\n  - name: api-service\n    group: backend\n    url: https://example.com/api\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: integration-tests\n    group: testing\n    interval: 30s\n    endpoints:\n      - name: step1\n        url: https://example.com/test\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"endpoint-external-endpoint-suite-unique-keys\",\n\t\t\tshouldError: false,\n\t\t\tconfig: `\nendpoints:\n  - name: api-service\n    group: backend\n    url: https://example.com/api\n    conditions:\n      - \"[STATUS] == 200\"\n\nexternal-endpoints:\n  - name: monitoring-agent\n    group: infrastructure\n    token: \"secret-token\"\n    heartbeat:\n      interval: 5m\n\nsuites:\n  - name: integration-tests\n    group: testing\n    interval: 30s\n    endpoints:\n      - name: step1\n        url: https://example.com/test\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"suite-with-same-key-as-external-endpoint\",\n\t\t\tshouldError: true,\n\t\t\texpectedErr: \"duplicate key 'monitoring_health-check': suite 'monitoring_health-check' conflicts with external endpoint 'monitoring_health-check'\",\n\t\t\tconfig: `\nendpoints:\n  - name: dummy\n    url: https://example.com/dummy\n    conditions:\n      - \"[STATUS] == 200\"\n\nexternal-endpoints:\n  - name: health-check\n    group: monitoring\n    token: \"secret-token\"\n    heartbeat:\n      interval: 5m\n\nsuites:\n  - name: health-check\n    group: monitoring\n    interval: 30s\n    endpoints:\n      - name: step1\n        url: https://example.com/test\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"endpoint-with-same-name-as-suite-endpoint-different-groups\",\n\t\t\tshouldError: false,\n\t\t\tconfig: `\nendpoints:\n  - name: api-health\n    group: backend\n    url: https://example.com/health\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: integration-suite\n    group: testing\n    interval: 30s\n    endpoints:\n      - name: api-health\n        url: https://example.com/api/health\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"endpoint-conflicting-with-suite-endpoint\",\n\t\t\tshouldError: true,\n\t\t\texpectedErr: \"duplicate key 'backend_api-health': endpoint 'backend_api-health' in suite 'backend_integration-suite' conflicts with endpoint 'backend_api-health'\",\n\t\t\tconfig: `\nendpoints:\n  - name: api-health\n    group: backend\n    url: https://example.com/health\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: integration-suite\n    group: backend\n    interval: 30s\n    endpoints:\n      - name: api-health\n        url: https://example.com/api/health\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\t_, err := parseAndValidateConfigBytes([]byte(scenario.config))\n\t\t\tif scenario.shouldError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"should've returned an error\")\n\t\t\t\t} else if scenario.expectedErr != \"\" && err.Error() != scenario.expectedErr {\n\t\t\t\t\tt.Errorf(\"expected error message '%s', got '%s'\", scenario.expectedErr, err.Error())\n\t\t\t\t}\n\t\t\t} else if err != nil {\n\t\t\t\tt.Errorf(\"shouldn't have returned an error, got: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestParseAndValidateConfigBytesWithSuites(t *testing.T) {\n\tscenarios := []struct {\n\t\tname        string\n\t\tshouldError bool\n\t\texpectedErr string\n\t\tconfig      string\n\t}{\n\t\t{\n\t\t\tname:        \"suite-with-no-name\",\n\t\t\tshouldError: true,\n\t\t\texpectedErr: \"invalid suite 'testing_': suite must have a name\",\n\t\t\tconfig: `\nendpoints:\n  - name: dummy\n    url: https://example.com/dummy\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - group: testing\n    interval: 30s\n    endpoints:\n      - name: step1\n        url: https://example.com/test\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"suite-with-no-endpoints\",\n\t\t\tshouldError: true,\n\t\t\texpectedErr: \"invalid suite 'testing_empty-suite': suite must have at least one endpoint\",\n\t\t\tconfig: `\nendpoints:\n  - name: dummy\n    url: https://example.com/dummy\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: empty-suite\n    group: testing\n    interval: 30s\n    endpoints: []`,\n\t\t},\n\t\t{\n\t\t\tname:        \"suite-with-duplicate-endpoint-names\",\n\t\t\tshouldError: true,\n\t\t\texpectedErr: \"invalid suite 'testing_duplicate-test': suite cannot have duplicate endpoint names: duplicate endpoint name 'step1'\",\n\t\t\tconfig: `\nendpoints:\n  - name: dummy\n    url: https://example.com/dummy\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: duplicate-test\n    group: testing\n    interval: 30s\n    endpoints:\n      - name: step1\n        url: https://example.com/test1\n        conditions:\n          - \"[STATUS] == 200\"\n      - name: step1\n        url: https://example.com/test2\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"suite-with-invalid-negative-timeout\",\n\t\t\tshouldError: true,\n\t\t\texpectedErr: \"invalid suite 'testing_negative-timeout-suite': suite timeout must be positive\",\n\t\t\tconfig: `\nendpoints:\n  - name: dummy\n    url: https://example.com/dummy\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: negative-timeout-suite\n    group: testing\n    interval: 30s\n    timeout: -5m\n    endpoints:\n      - name: step1\n        url: https://example.com/test\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid-suite-with-defaults\",\n\t\t\tshouldError: false,\n\t\t\tconfig: `\nendpoints:\n  - name: api-service\n    group: backend\n    url: https://example.com/api\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: integration-test\n    group: testing\n    endpoints:\n      - name: step1\n        url: https://example.com/test\n        conditions:\n          - \"[STATUS] == 200\"\n      - name: step2\n        url: https://example.com/validate\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid-suite-with-all-fields\",\n\t\t\tshouldError: false,\n\t\t\tconfig: `\nendpoints:\n  - name: api-service\n    group: backend\n    url: https://example.com/api\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: full-integration-test\n    group: testing\n    enabled: true\n    interval: 15m\n    timeout: 10m\n    context:\n      base_url: \"https://example.com\"\n      user_id: 12345\n    endpoints:\n      - name: authentication\n        url: https://example.com/auth\n        conditions:\n          - \"[STATUS] == 200\"\n      - name: user-profile\n        url: https://example.com/profile\n        conditions:\n          - \"[STATUS] == 200\"\n          - \"[BODY].user_id == 12345\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid-suite-with-endpoint-inheritance\",\n\t\t\tshouldError: false,\n\t\t\tconfig: `\nendpoints:\n  - name: api-service\n    group: backend\n    url: https://example.com/api\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: inheritance-test\n    group: parent-group\n    endpoints:\n      - name: child-endpoint\n        url: https://example.com/test\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid-suite-with-store-functionality\",\n\t\t\tshouldError: false,\n\t\t\tconfig: `\nendpoints:\n  - name: api-service\n    group: backend\n    url: https://example.com/api\n    conditions:\n      - \"[STATUS] == 200\"\n\nsuites:\n  - name: store-test\n    group: testing\n    endpoints:\n      - name: get-token\n        url: https://example.com/auth\n        conditions:\n          - \"[STATUS] == 200\"\n        store:\n          auth_token: \"[BODY].token\"\n      - name: use-token\n        url: https://example.com/data\n        headers:\n          Authorization: \"Bearer {auth_token}\"\n        conditions:\n          - \"[STATUS] == 200\"`,\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\t_, err := parseAndValidateConfigBytes([]byte(scenario.config))\n\t\t\tif scenario.shouldError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"should've returned an error\")\n\t\t\t\t} else if scenario.expectedErr != \"\" && err.Error() != scenario.expectedErr {\n\t\t\t\t\tt.Errorf(\"expected error message '%s', got '%s'\", scenario.expectedErr, err.Error())\n\t\t\t\t}\n\t\t\t} else if err != nil {\n\t\t\t\tt.Errorf(\"shouldn't have returned an error, got: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestValidateTunnelingConfig(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tconfig  *Config\n\t\twantErr bool\n\t\terrMsg  string\n\t}{\n\t\t{\n\t\t\tname: \"valid tunneling config\",\n\t\t\tconfig: &Config{\n\t\t\t\tTunneling: &tunneling.Config{\n\t\t\t\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\t\t\t\"test\": {\n\t\t\t\t\t\t\tType:     \"SSH\",\n\t\t\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\t\t\tUsername: \"test\",\n\t\t\t\t\t\t\tPassword: \"secret\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"test-endpoint\",\n\t\t\t\t\t\tURL:  \"http://example.com/health\",\n\t\t\t\t\t\tClientConfig: &client.Config{\n\t\t\t\t\t\t\tTunnel: \"test\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tConditions: []endpoint.Condition{\"[STATUS] == 200\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid tunnel reference in endpoint\",\n\t\t\tconfig: &Config{\n\t\t\t\tTunneling: &tunneling.Config{\n\t\t\t\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\t\t\t\"test\": {\n\t\t\t\t\t\t\tType:     \"SSH\",\n\t\t\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\t\t\tUsername: \"test\",\n\t\t\t\t\t\t\tPassword: \"secret\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"test-endpoint\",\n\t\t\t\t\t\tURL:  \"http://example.com/health\",\n\t\t\t\t\t\tClientConfig: &client.Config{\n\t\t\t\t\t\t\tTunnel: \"nonexistent\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tConditions: []endpoint.Condition{\"[STATUS] == 200\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"endpoint '_test-endpoint': tunnel 'nonexistent' not found in tunneling configuration\",\n\t\t},\n\t\t{\n\t\t\tname: \"invalid tunnel reference in suite endpoint\",\n\t\t\tconfig: &Config{\n\t\t\t\tTunneling: &tunneling.Config{\n\t\t\t\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\t\t\t\"test\": {\n\t\t\t\t\t\t\tType:     \"SSH\",\n\t\t\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\t\t\tUsername: \"test\",\n\t\t\t\t\t\t\tPassword: \"secret\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSuites: []*suite.Suite{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"test-suite\",\n\t\t\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tName: \"suite-endpoint\",\n\t\t\t\t\t\t\t\tURL:  \"http://example.com/health\",\n\t\t\t\t\t\t\t\tClientConfig: &client.Config{\n\t\t\t\t\t\t\t\t\tTunnel: \"invalid\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tConditions: []endpoint.Condition{\"[STATUS] == 200\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"suite '_test-suite' endpoint '_suite-endpoint': tunnel 'invalid' not found in tunneling configuration\",\n\t\t},\n\t\t{\n\t\t\tname: \"no tunneling config\",\n\t\t\tconfig: &Config{\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"test-endpoint\",\n\t\t\t\t\t\tURL:        \"http://example.com/health\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\"[STATUS] == 200\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := ValidateTunnelingConfig(tt.config)\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"ValidateTunnelingConfig() expected error but got none\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif err.Error() != tt.errMsg {\n\t\t\t\t\tt.Errorf(\"ValidateTunnelingConfig() error = %v, want %v\", err.Error(), tt.errMsg)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ValidateTunnelingConfig() unexpected error = %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveTunnelForClientConfig(t *testing.T) {\n\tconfig := &Config{\n\t\tTunneling: &tunneling.Config{\n\t\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\t\"test\": {\n\t\t\t\t\tType:     \"SSH\",\n\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\tUsername: \"test\",\n\t\t\t\t\tPassword: \"secret\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\terr := config.Tunneling.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to validate tunnel config: %v\", err)\n\t}\n\ttests := []struct {\n\t\tname         string\n\t\tclientConfig *client.Config\n\t\twantErr      bool\n\t\terrMsg       string\n\t}{\n\t\t{\n\t\t\tname: \"valid tunnel reference\",\n\t\t\tclientConfig: &client.Config{\n\t\t\t\tTunnel: \"test\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid tunnel reference\",\n\t\t\tclientConfig: &client.Config{\n\t\t\t\tTunnel: \"nonexistent\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"tunnel 'nonexistent' not found in tunneling configuration\",\n\t\t},\n\t\t{\n\t\t\tname:         \"no tunnel reference\",\n\t\t\tclientConfig: &client.Config{},\n\t\t\twantErr:      false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := resolveTunnelForClientConfig(config, tt.clientConfig)\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"resolveTunnelForClientConfig() expected error but got none\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif err.Error() != tt.errMsg {\n\t\t\t\t\tt.Errorf(\"resolveTunnelForClientConfig() error = %v, want %v\", err.Error(), tt.errMsg)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"resolveTunnelForClientConfig() unexpected error = %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "config/connectivity/connectivity.go",
    "content": "package connectivity\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/client\"\n)\n\nvar (\n\tErrInvalidInterval  = errors.New(\"connectivity.checker.interval must be 5s or higher\")\n\tErrInvalidDNSTarget = errors.New(\"connectivity.checker.target must be suffixed with :53\")\n)\n\n// Config is the configuration for the connectivity checker.\ntype Config struct {\n\tChecker *Checker `yaml:\"checker,omitempty\"`\n}\n\nfunc (c *Config) ValidateAndSetDefaults() error {\n\tif c.Checker != nil {\n\t\tif c.Checker.Interval == 0 {\n\t\t\tc.Checker.Interval = 60 * time.Second\n\t\t} else if c.Checker.Interval < 5*time.Second {\n\t\t\treturn ErrInvalidInterval\n\t\t}\n\t\tif !strings.HasSuffix(c.Checker.Target, \":53\") {\n\t\t\treturn ErrInvalidDNSTarget\n\t\t}\n\t}\n\treturn nil\n}\n\n// Checker is the configuration for making sure Gatus has access to the internet.\ntype Checker struct {\n\tTarget   string        `yaml:\"target\"` // e.g. 1.1.1.1:53\n\tInterval time.Duration `yaml:\"interval,omitempty\"`\n\n\tisConnected bool\n\tlastCheck   time.Time\n}\n\nfunc (c *Checker) Check() bool {\n\tconnected, _ := client.CanCreateNetworkConnection(\"tcp\", c.Target, \"\", &client.Config{Timeout: 5 * time.Second})\n\treturn connected\n}\n\nfunc (c *Checker) IsConnected() bool {\n\tif now := time.Now(); now.After(c.lastCheck.Add(c.Interval)) {\n\t\tc.lastCheck, c.isConnected = now, c.Check()\n\t}\n\treturn c.isConnected\n}\n"
  },
  {
    "path": "config/connectivity/connectivity_test.go",
    "content": "package connectivity\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestConfig(t *testing.T) {\n\tscenarios := []struct {\n\t\tname             string\n\t\tcfg              *Config\n\t\texpectedErr      error\n\t\texpectedInterval time.Duration\n\t}{\n\t\t{\n\t\t\tname:             \"good-config\",\n\t\t\tcfg:              &Config{Checker: &Checker{Target: \"1.1.1.1:53\", Interval: 10 * time.Second}},\n\t\t\texpectedInterval: 10 * time.Second,\n\t\t},\n\t\t{\n\t\t\tname:             \"good-config-with-default-interval\",\n\t\t\tcfg:              &Config{Checker: &Checker{Target: \"8.8.8.8:53\", Interval: 0}},\n\t\t\texpectedInterval: 60 * time.Second,\n\t\t},\n\t\t{\n\t\t\tname:        \"config-with-interval-too-low\",\n\t\t\tcfg:         &Config{Checker: &Checker{Target: \"1.1.1.1:53\", Interval: 4 * time.Second}},\n\t\t\texpectedErr: ErrInvalidInterval,\n\t\t},\n\t\t{\n\t\t\tname:        \"config-with-invalid-target-due-to-missing-port\",\n\t\t\tcfg:         &Config{Checker: &Checker{Target: \"1.1.1.1\", Interval: 15 * time.Second}},\n\t\t\texpectedErr: ErrInvalidDNSTarget,\n\t\t},\n\t\t{\n\t\t\tname:        \"config-with-invalid-target-due-to-invalid-dns-port\",\n\t\t\tcfg:         &Config{Checker: &Checker{Target: \"1.1.1.1:52\", Interval: 15 * time.Second}},\n\t\t\texpectedErr: ErrInvalidDNSTarget,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.cfg.ValidateAndSetDefaults()\n\t\t\tif fmt.Sprintf(\"%s\", err) != fmt.Sprintf(\"%s\", scenario.expectedErr) {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.expectedErr, err)\n\t\t\t}\n\t\t\tif err == nil && scenario.expectedErr == nil {\n\t\t\t\tif scenario.cfg.Checker.Interval != scenario.expectedInterval {\n\t\t\t\t\tt.Errorf(\"expected interval %v, got %v\", scenario.expectedInterval, scenario.cfg.Checker.Interval)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestChecker_IsConnected(t *testing.T) {\n\tchecker := &Checker{Target: \"1.1.1.1:53\", Interval: 10 * time.Second}\n\tif !checker.IsConnected() {\n\t\tt.Error(\"expected checker.IsConnected() to be true\")\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/common.go",
    "content": "package endpoint\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n)\n\nvar (\n\t// ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name\n\tErrEndpointWithNoName = errors.New(\"you must specify a name for each endpoint\")\n\n\t// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't\n\tErrEndpointWithInvalidNameOrGroup = errors.New(\"endpoint name and group must not have \\\" or \\\\\")\n)\n\n// validateEndpointNameGroupAndAlerts validates the name, group and alerts of an endpoint\nfunc validateEndpointNameGroupAndAlerts(name, group string, alerts []*alert.Alert) error {\n\tif len(name) == 0 {\n\t\treturn ErrEndpointWithNoName\n\t}\n\tif strings.ContainsAny(name, \"\\\"\\\\\") || strings.ContainsAny(group, \"\\\"\\\\\") {\n\t\treturn ErrEndpointWithInvalidNameOrGroup\n\t}\n\tfor _, endpointAlert := range alerts {\n\t\tif err := endpointAlert.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "config/endpoint/common_test.go",
    "content": "package endpoint\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n)\n\nfunc TestValidateEndpointNameGroupAndAlerts(t *testing.T) {\n\tscenarios := []struct {\n\t\tname        string\n\t\tgroup       string\n\t\talerts      []*alert.Alert\n\t\texpectedErr error\n\t}{\n\t\t{\n\t\t\tname:   \"n\",\n\t\t\tgroup:  \"g\",\n\t\t\talerts: []*alert.Alert{{Type: \"slack\"}},\n\t\t},\n\t\t{\n\t\t\tname:   \"n\",\n\t\t\talerts: []*alert.Alert{{Type: \"slack\"}},\n\t\t},\n\t\t{\n\t\t\tgroup:       \"g\",\n\t\t\talerts:      []*alert.Alert{{Type: \"slack\"}},\n\t\t\texpectedErr: ErrEndpointWithNoName,\n\t\t},\n\t\t{\n\t\t\tname:        \"\\\"\",\n\t\t\talerts:      []*alert.Alert{{Type: \"slack\"}},\n\t\t\texpectedErr: ErrEndpointWithInvalidNameOrGroup,\n\t\t},\n\t\t{\n\t\t\tname:        \"n\",\n\t\t\tgroup:       \"\\\\\",\n\t\t\talerts:      []*alert.Alert{{Type: \"slack\"}},\n\t\t\texpectedErr: ErrEndpointWithInvalidNameOrGroup,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := validateEndpointNameGroupAndAlerts(scenario.name, scenario.group, scenario.alerts)\n\t\t\tif !errors.Is(err, scenario.expectedErr) {\n\t\t\t\tt.Errorf(\"expected error to be %v but got %v\", scenario.expectedErr, err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/condition.go",
    "content": "package endpoint\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/gontext\"\n\t\"github.com/TwiN/gatus/v5/pattern\"\n)\n\nconst (\n\t// maximumLengthBeforeTruncatingWhenComparedWithPattern is the maximum length an element being compared to a\n\t// pattern can have.\n\t//\n\t// This is only used for aesthetic purposes; it does not influence whether the condition evaluation results in a\n\t// success or a failure\n\tmaximumLengthBeforeTruncatingWhenComparedWithPattern = 25\n)\n\n// Condition is a condition that needs to be met in order for an Endpoint to be considered healthy.\ntype Condition string\n\n// Validate checks if the Condition is valid\nfunc (c Condition) Validate() error {\n\tr := &Result{}\n\tc.evaluate(r, false, false, nil)\n\tif len(r.Errors) != 0 {\n\t\treturn errors.New(r.Errors[0])\n\t}\n\treturn nil\n}\n\n// evaluate the Condition with the Result and an optional context\nfunc (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, resolveSuccessfulConditions bool, context *gontext.Gontext) bool {\n\tcondition := string(c)\n\tsuccess := false\n\tconditionToDisplay := condition\n\tshouldResolveCondition := func(success bool) bool {\n\t\tif success {\n\t\t\treturn resolveSuccessfulConditions\n\t\t}\n\t\treturn !dontResolveFailedConditions\n\t}\n\tif strings.Contains(condition, \" == \") {\n\t\tparameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, \" == \"), result, context)\n\t\tsuccess = isEqual(resolvedParameters[0], resolvedParameters[1])\n\t\tif shouldResolveCondition(success) {\n\t\t\tconditionToDisplay = prettify(parameters, resolvedParameters, \"==\")\n\t\t}\n\t} else if strings.Contains(condition, \" != \") {\n\t\tparameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, \" != \"), result, context)\n\t\tsuccess = !isEqual(resolvedParameters[0], resolvedParameters[1])\n\t\tif shouldResolveCondition(success) {\n\t\t\tconditionToDisplay = prettify(parameters, resolvedParameters, \"!=\")\n\t\t}\n\t} else if strings.Contains(condition, \" <= \") {\n\t\tparameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, \" <= \"), result, context)\n\t\tsuccess = resolvedParameters[0] <= resolvedParameters[1]\n\t\tif shouldResolveCondition(success) {\n\t\t\tconditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, \"<=\")\n\t\t}\n\t} else if strings.Contains(condition, \" >= \") {\n\t\tparameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, \" >= \"), result, context)\n\t\tsuccess = resolvedParameters[0] >= resolvedParameters[1]\n\t\tif shouldResolveCondition(success) {\n\t\t\tconditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, \">=\")\n\t\t}\n\t} else if strings.Contains(condition, \" > \") {\n\t\tparameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, \" > \"), result, context)\n\t\tsuccess = resolvedParameters[0] > resolvedParameters[1]\n\t\tif shouldResolveCondition(success) {\n\t\t\tconditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, \">\")\n\t\t}\n\t} else if strings.Contains(condition, \" < \") {\n\t\tparameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, \" < \"), result, context)\n\t\tsuccess = resolvedParameters[0] < resolvedParameters[1]\n\t\tif shouldResolveCondition(success) {\n\t\t\tconditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, \"<\")\n\t\t}\n\t} else {\n\t\tresult.AddError(fmt.Sprintf(\"invalid condition: %s\", condition))\n\t\treturn false\n\t}\n\tif !success {\n\t\t//logr.Debugf(\"[Condition.evaluate] Condition '%s' did not succeed because '%s' is false\", condition, condition)\n\t}\n\tresult.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})\n\treturn success\n}\n\n// hasBodyPlaceholder checks whether the condition has a BodyPlaceholder\n// Used for determining whether the response body should be read or not\nfunc (c Condition) hasBodyPlaceholder() bool {\n\treturn strings.Contains(string(c), BodyPlaceholder)\n}\n\n// hasDomainExpirationPlaceholder checks whether the condition has a DomainExpirationPlaceholder\n// Used for determining whether a whois operation is necessary\nfunc (c Condition) hasDomainExpirationPlaceholder() bool {\n\treturn strings.Contains(string(c), DomainExpirationPlaceholder)\n}\n\n// hasIPPlaceholder checks whether the condition has an IPPlaceholder\n// Used for determining whether an IP lookup is necessary\nfunc (c Condition) hasIPPlaceholder() bool {\n\treturn strings.Contains(string(c), IPPlaceholder)\n}\n\n// isEqual compares two strings.\n//\n// Supports the \"pat\" and the \"any\" functions.\n// i.e. if one of the parameters starts with PatternFunctionPrefix and ends with FunctionSuffix, it will be treated like\n// a pattern.\nfunc isEqual(first, second string) bool {\n\tfirstHasFunctionSuffix := strings.HasSuffix(first, FunctionSuffix)\n\tsecondHasFunctionSuffix := strings.HasSuffix(second, FunctionSuffix)\n\tif firstHasFunctionSuffix || secondHasFunctionSuffix {\n\t\tvar isFirstPattern, isSecondPattern bool\n\t\tif strings.HasPrefix(first, PatternFunctionPrefix) && firstHasFunctionSuffix {\n\t\t\tisFirstPattern = true\n\t\t\tfirst = strings.TrimSuffix(strings.TrimPrefix(first, PatternFunctionPrefix), FunctionSuffix)\n\t\t}\n\t\tif strings.HasPrefix(second, PatternFunctionPrefix) && secondHasFunctionSuffix {\n\t\t\tisSecondPattern = true\n\t\t\tsecond = strings.TrimSuffix(strings.TrimPrefix(second, PatternFunctionPrefix), FunctionSuffix)\n\t\t}\n\t\tif isFirstPattern && !isSecondPattern {\n\t\t\treturn pattern.Match(first, second)\n\t\t} else if !isFirstPattern && isSecondPattern {\n\t\t\treturn pattern.Match(second, first)\n\t\t}\n\t\tvar isFirstAny, isSecondAny bool\n\t\tif strings.HasPrefix(first, AnyFunctionPrefix) && firstHasFunctionSuffix {\n\t\t\tisFirstAny = true\n\t\t\tfirst = strings.TrimSuffix(strings.TrimPrefix(first, AnyFunctionPrefix), FunctionSuffix)\n\t\t}\n\t\tif strings.HasPrefix(second, AnyFunctionPrefix) && secondHasFunctionSuffix {\n\t\t\tisSecondAny = true\n\t\t\tsecond = strings.TrimSuffix(strings.TrimPrefix(second, AnyFunctionPrefix), FunctionSuffix)\n\t\t}\n\t\tif isFirstAny && !isSecondAny {\n\t\t\toptions := strings.Split(first, \",\")\n\t\t\tfor _, option := range options {\n\t\t\t\tif strings.TrimSpace(option) == second {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false\n\t\t} else if !isFirstAny && isSecondAny {\n\t\t\toptions := strings.Split(second, \",\")\n\t\t\tfor _, option := range options {\n\t\t\t\tif strings.TrimSpace(option) == first {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// test if inputs are integers\n\tfirstInt, err1 := strconv.ParseInt(first, 0, 64)\n\tsecondInt, err2 := strconv.ParseInt(second, 0, 64)\n\tif err1 == nil && err2 == nil {\n\t\treturn firstInt == secondInt\n\t}\n\n\treturn first == second\n}\n\n// sanitizeAndResolveWithContext sanitizes and resolves a list of elements with an optional context\nfunc sanitizeAndResolveWithContext(elements []string, result *Result, context *gontext.Gontext) ([]string, []string) {\n\tparameters := make([]string, len(elements))\n\tresolvedParameters := make([]string, len(elements))\n\tfor i, element := range elements {\n\t\telement = strings.TrimSpace(element)\n\t\tparameters[i] = element\n\n\t\t// Use the unified ResolvePlaceholder function\n\t\tresolved, err := ResolvePlaceholder(element, result, context)\n\t\tif err != nil {\n\t\t\t// If there's an error, add it to the result\n\t\t\tresult.AddError(err.Error())\n\t\t\tresolvedParameters[i] = element + \" \" + InvalidConditionElementSuffix\n\t\t} else {\n\t\t\tresolvedParameters[i] = resolved\n\t\t}\n\t}\n\treturn parameters, resolvedParameters\n}\n\nfunc sanitizeAndResolveNumericalWithContext(list []string, result *Result, context *gontext.Gontext) (parameters []string, resolvedNumericalParameters []int64) {\n\tparameters, resolvedParameters := sanitizeAndResolveWithContext(list, result, context)\n\tfor _, element := range resolvedParameters {\n\t\tif duration, err := time.ParseDuration(element); duration != 0 && err == nil {\n\t\t\t// If the string is a duration, convert it to milliseconds\n\t\t\tresolvedNumericalParameters = append(resolvedNumericalParameters, duration.Milliseconds())\n\t\t} else if number, err := strconv.ParseInt(element, 0, 64); err != nil {\n\t\t\t// It's not an int, so we'll check if it's a float\n\t\t\tif f, err := strconv.ParseFloat(element, 64); err == nil {\n\t\t\t\t// It's a float, but we'll convert it to an int. We're losing precision here, but it's better than\n\t\t\t\t// just returning 0.\n\t\t\t\tresolvedNumericalParameters = append(resolvedNumericalParameters, int64(f))\n\t\t\t} else {\n\t\t\t\t// Default to 0 if the string couldn't be converted to an integer or a float\n\t\t\t\tresolvedNumericalParameters = append(resolvedNumericalParameters, 0)\n\t\t\t}\n\t\t} else {\n\t\t\tresolvedNumericalParameters = append(resolvedNumericalParameters, number)\n\t\t}\n\t}\n\treturn parameters, resolvedNumericalParameters\n}\n\nfunc prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {\n\tresolvedStrings := make([]string, 2)\n\tfor i := range 2 {\n\t\t// Check if the parameter is a certificate or domain expiration placeholder\n\t\tif parameters[i] == CertificateExpirationPlaceholder || parameters[i] == DomainExpirationPlaceholder {\n\t\t\t// Format as duration string (convert milliseconds back to duration)\n\t\t\tduration := time.Duration(resolvedParameters[i]) * time.Millisecond\n\t\t\tresolvedStrings[i] = formatDuration(duration)\n\t\t} else if _, err := time.ParseDuration(parameters[i]); err == nil {\n\t\t\t// If the original parameter was a duration string (like \"48h\"), format the resolved value\n\t\t\t// as a duration string too so it matches and doesn't show in parentheses\n\t\t\tduration := time.Duration(resolvedParameters[i]) * time.Millisecond\n\t\t\tresolvedStrings[i] = formatDuration(duration)\n\t\t} else {\n\t\t\t// Format as integer\n\t\t\tresolvedStrings[i] = strconv.Itoa(int(resolvedParameters[i]))\n\t\t}\n\t}\n\treturn prettify(parameters, resolvedStrings, operator)\n}\n\n// formatDuration formats a duration in a clean, human-readable way by removing unnecessary zero components.\n// For example: 336h0m0s becomes 336h, 1h30m0s becomes 1h30m, but 1h0m15s stays as 1h0m15s.\n// Truncates to whole seconds to avoid decimal values like 7353h5m54.67s.\nfunc formatDuration(d time.Duration) string {\n\t// Truncate to whole seconds to avoid decimal seconds\n\td = d.Truncate(time.Second)\n\ts := d.String()\n\t// Special case: if duration is zero, return \"0s\"\n\tif s == \"0s\" {\n\t\treturn \"0s\"\n\t}\n\t// Remove trailing \"0s\" if present\n\tif strings.HasSuffix(s, \"0s\") {\n\t\ts = strings.TrimSuffix(s, \"0s\")\n\t\t// Remove trailing \"0m\" if present after removing \"0s\"\n\t\ts = strings.TrimSuffix(s, \"0m\")\n\t}\n\treturn s\n}\n\n// prettify returns a string representation of a condition with its parameters resolved between parentheses\nfunc prettify(parameters []string, resolvedParameters []string, operator string) string {\n\t// Handle pattern function truncation first\n\tif strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {\n\t\tresolvedParameters[1] = fmt.Sprintf(\"%.25s...(truncated)\", resolvedParameters[1])\n\t}\n\tif strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {\n\t\tresolvedParameters[0] = fmt.Sprintf(\"%.25s...(truncated)\", resolvedParameters[0])\n\t}\n\t// Determine the state of each parameter\n\tleftChanged := parameters[0] != resolvedParameters[0]\n\trightChanged := parameters[1] != resolvedParameters[1]\n\tleftInvalid := resolvedParameters[0] == parameters[0]+\" \"+InvalidConditionElementSuffix\n\trightInvalid := resolvedParameters[1] == parameters[1]+\" \"+InvalidConditionElementSuffix\n\t// Build the output based on what was resolved\n\tvar left, right string\n\t// Format left side\n\tif leftChanged && !leftInvalid {\n\t\tleft = parameters[0] + \" (\" + resolvedParameters[0] + \")\"\n\t} else if leftInvalid {\n\t\tleft = resolvedParameters[0] // Already has (INVALID)\n\t} else {\n\t\tleft = parameters[0] // Unchanged\n\t}\n\t// Format right side\n\tif rightChanged && !rightInvalid {\n\t\tright = parameters[1] + \" (\" + resolvedParameters[1] + \")\"\n\t} else if rightInvalid {\n\t\tright = resolvedParameters[1] // Already has (INVALID)\n\t} else {\n\t\tright = parameters[1] // Unchanged\n\t}\n\treturn left + \" \" + operator + \" \" + right\n}\n"
  },
  {
    "path": "config/endpoint/condition_bench_test.go",
    "content": "package endpoint\n\nimport (\n\t\"testing\"\n)\n\nfunc BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {\n\tcondition := Condition(\"[BODY].name == any(john.doe, jane.doe)\")\n\tfor n := 0; n < b.N; n++ {\n\t\tresult := &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")}\n\t\tcondition.evaluate(result, false, false, nil)\n\t}\n\tb.ReportAllocs()\n}\n\nfunc BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {\n\tcondition := Condition(\"[BODY].name == any(john.doe, jane.doe)\")\n\tfor n := 0; n < b.N; n++ {\n\t\tresult := &Result{Body: []byte(\"{\\\"name\\\": \\\"bob.doe\\\"}\")}\n\t\tcondition.evaluate(result, false, false, nil)\n\t}\n\tb.ReportAllocs()\n}\n\nfunc BenchmarkCondition_evaluateWithBodyString(b *testing.B) {\n\tcondition := Condition(\"[BODY].name == john.doe\")\n\tfor n := 0; n < b.N; n++ {\n\t\tresult := &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")}\n\t\tcondition.evaluate(result, false, false, nil)\n\t}\n\tb.ReportAllocs()\n}\n\nfunc BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {\n\tcondition := Condition(\"[BODY].name == john.doe\")\n\tfor n := 0; n < b.N; n++ {\n\t\tresult := &Result{Body: []byte(\"{\\\"name\\\": \\\"bob.doe\\\"}\")}\n\t\tcondition.evaluate(result, false, false, nil)\n\t}\n\tb.ReportAllocs()\n}\n\nfunc BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {\n\tcondition := Condition(\"[BODY].user.name == bob.doe\")\n\tfor n := 0; n < b.N; n++ {\n\t\tresult := &Result{Body: []byte(\"{\\\"name\\\": \\\"bob.doe\\\"}\")}\n\t\tcondition.evaluate(result, false, false, nil)\n\t}\n\tb.ReportAllocs()\n}\n\nfunc BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {\n\tcondition := Condition(\"len([BODY].name) == 8\")\n\tfor n := 0; n < b.N; n++ {\n\t\tresult := &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")}\n\t\tcondition.evaluate(result, false, false, nil)\n\t}\n\tb.ReportAllocs()\n}\n\nfunc BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {\n\tcondition := Condition(\"len([BODY].name) == 8\")\n\tfor n := 0; n < b.N; n++ {\n\t\tresult := &Result{Body: []byte(\"{\\\"name\\\": \\\"bob.doe\\\"}\")}\n\t\tcondition.evaluate(result, false, false, nil)\n\t}\n\tb.ReportAllocs()\n}\n\nfunc BenchmarkCondition_evaluateWithStatus(b *testing.B) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tfor n := 0; n < b.N; n++ {\n\t\tresult := &Result{HTTPStatus: 200}\n\t\tcondition.evaluate(result, false, false, nil)\n\t}\n\tb.ReportAllocs()\n}\n\nfunc BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tfor n := 0; n < b.N; n++ {\n\t\tresult := &Result{HTTPStatus: 400}\n\t\tcondition.evaluate(result, false, false, nil)\n\t}\n\tb.ReportAllocs()\n}\n"
  },
  {
    "path": "config/endpoint/condition_result.go",
    "content": "package endpoint\n\n// ConditionResult result of a Condition\ntype ConditionResult struct {\n\t// Condition that was evaluated\n\tCondition string `json:\"condition\"`\n\n\t// Success whether the condition was met (successful) or not (failed)\n\tSuccess bool `json:\"success\"`\n}\n"
  },
  {
    "path": "config/endpoint/condition_test.go",
    "content": "package endpoint\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/gontext\"\n)\n\nfunc TestCondition_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tcondition   Condition\n\t\texpectedErr error\n\t}{\n\t\t{condition: \"[STATUS] == 200\", expectedErr: nil},\n\t\t{condition: \"[STATUS] != 200\", expectedErr: nil},\n\t\t{condition: \"[STATUS] <= 200\", expectedErr: nil},\n\t\t{condition: \"[STATUS] >= 200\", expectedErr: nil},\n\t\t{condition: \"[STATUS] < 200\", expectedErr: nil},\n\t\t{condition: \"[STATUS] > 200\", expectedErr: nil},\n\t\t{condition: \"[STATUS] == any(200, 201, 202, 203)\", expectedErr: nil},\n\t\t{condition: \"[STATUS] == [BODY].status\", expectedErr: nil},\n\t\t{condition: \"[CONNECTED] == true\", expectedErr: nil},\n\t\t{condition: \"[RESPONSE_TIME] < 500\", expectedErr: nil},\n\t\t{condition: \"[IP] == 127.0.0.1\", expectedErr: nil},\n\t\t{condition: \"[BODY] == 1\", expectedErr: nil},\n\t\t{condition: \"[BODY].test == wat\", expectedErr: nil},\n\t\t{condition: \"[BODY].test.wat == wat\", expectedErr: nil},\n\t\t{condition: \"[BODY].age == [BODY].id\", expectedErr: nil},\n\t\t{condition: \"[BODY].users[0].id == 1\", expectedErr: nil},\n\t\t{condition: \"len([BODY].users) == 100\", expectedErr: nil},\n\t\t{condition: \"len([BODY].data) < 5\", expectedErr: nil},\n\t\t{condition: \"has([BODY].errors) == false\", expectedErr: nil},\n\t\t{condition: \"has([BODY].users[0].name) == true\", expectedErr: nil},\n\t\t{condition: \"[BODY].name == pat(john*)\", expectedErr: nil},\n\t\t{condition: \"[CERTIFICATE_EXPIRATION] > 48h\", expectedErr: nil},\n\t\t{condition: \"[DOMAIN_EXPIRATION] > 720h\", expectedErr: nil},\n\t\t{condition: \"raw == raw\", expectedErr: nil},\n\t\t{condition: \"[STATUS] ? 201\", expectedErr: errors.New(\"invalid condition: [STATUS] ? 201\")},\n\t\t{condition: \"[STATUS]==201\", expectedErr: errors.New(\"invalid condition: [STATUS]==201\")},\n\t\t{condition: \"[STATUS] = = 201\", expectedErr: errors.New(\"invalid condition: [STATUS] = = 201\")},\n\t\t{condition: \"[STATUS] ==\", expectedErr: errors.New(\"invalid condition: [STATUS] ==\")},\n\t\t{condition: \"[STATUS]\", expectedErr: errors.New(\"invalid condition: [STATUS]\")},\n\t\t// FIXME: Should return an error, but doesn't because jsonpath isn't evaluated due to body being empty in Condition.Validate()\n\t\t//{condition: \"len([BODY].users == 100\", expectedErr: nil},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(string(scenario.condition), func(t *testing.T) {\n\t\t\tif err := scenario.condition.Validate(); fmt.Sprint(err) != fmt.Sprint(scenario.expectedErr) {\n\t\t\t\tt.Errorf(\"expected err %v, got %v\", scenario.expectedErr, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCondition_evaluate(t *testing.T) {\n\tscenarios := []struct {\n\t\tName                        string\n\t\tCondition                   Condition\n\t\tResult                      *Result\n\t\tDontResolveFailedConditions bool\n\t\tResolveSuccessfulConditions bool\n\t\tExpectedSuccess             bool\n\t\tExpectedOutput              string\n\t}{\n\t\t{\n\t\t\tName:            \"ip\",\n\t\t\tCondition:       Condition(\"[IP] == 127.0.0.1\"),\n\t\t\tResult:          &Result{IP: \"127.0.0.1\"},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[IP] == 127.0.0.1\",\n\t\t},\n\t\t{\n\t\t\tName:            \"status\",\n\t\t\tCondition:       Condition(\"[STATUS] == 200\"),\n\t\t\tResult:          &Result{HTTPStatus: 200},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[STATUS] == 200\",\n\t\t},\n\t\t{\n\t\t\tName:            \"status-failure\",\n\t\t\tCondition:       Condition(\"[STATUS] == 200\"),\n\t\t\tResult:          &Result{HTTPStatus: 500},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[STATUS] (500) == 200\",\n\t\t},\n\t\t{\n\t\t\tName:            \"status-using-less-than\",\n\t\t\tCondition:       Condition(\"[STATUS] < 300\"),\n\t\t\tResult:          &Result{HTTPStatus: 201},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[STATUS] < 300\",\n\t\t},\n\t\t{\n\t\t\tName:            \"status-using-less-than-failure\",\n\t\t\tCondition:       Condition(\"[STATUS] < 300\"),\n\t\t\tResult:          &Result{HTTPStatus: 404},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[STATUS] (404) < 300\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-less-than\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] < 500\"),\n\t\t\tResult:          &Result{Duration: 50 * time.Millisecond},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] < 500\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-less-than-with-duration\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] < 1s\"),\n\t\t\tResult:          &Result{Duration: 50 * time.Millisecond},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] < 1s\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-less-than-invalid\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] < potato\"),\n\t\t\tResult:          &Result{Duration: 50 * time.Millisecond},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] (50) < potato (0)\", // Non-numerical values automatically resolve to 0\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-greater-than\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] > 500\"),\n\t\t\tResult:          &Result{Duration: 750 * time.Millisecond},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] > 500\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-greater-than-with-duration\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] > 1s\"),\n\t\t\tResult:          &Result{Duration: 2 * time.Second},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] > 1s\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-greater-than-or-equal-to-equal\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] >= 500\"),\n\t\t\tResult:          &Result{Duration: 500 * time.Millisecond},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] >= 500\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-greater-than-or-equal-to-greater\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] >= 500\"),\n\t\t\tResult:          &Result{Duration: 499 * time.Millisecond},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] (499) >= 500\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-greater-than-or-equal-to-failure\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] >= 500\"),\n\t\t\tResult:          &Result{Duration: 499 * time.Millisecond},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] (499) >= 500\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-less-than-or-equal-to-equal\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] <= 500\"),\n\t\t\tResult:          &Result{Duration: 500 * time.Millisecond},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] <= 500\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-less-than-or-equal-to-less\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] <= 500\"),\n\t\t\tResult:          &Result{Duration: 25 * time.Millisecond},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] <= 500\",\n\t\t},\n\t\t{\n\t\t\tName:            \"response-time-using-less-than-or-equal-to-failure\",\n\t\t\tCondition:       Condition(\"[RESPONSE_TIME] <= 500\"),\n\t\t\tResult:          &Result{Duration: 750 * time.Millisecond},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[RESPONSE_TIME] (750) <= 500\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body\",\n\t\t\tCondition:       Condition(\"[BODY] == test\"),\n\t\t\tResult:          &Result{Body: []byte(\"test\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY] == test\",\n\t\t},\n\t\t{\n\t\t\tName:                        \"body-resolved-on-success\",\n\t\t\tCondition:                   Condition(\"[BODY].status == UP\"),\n\t\t\tResult:                      &Result{Body: []byte(\"{\\\"status\\\":\\\"UP\\\"}\")},\n\t\t\tResolveSuccessfulConditions: true,\n\t\t\tExpectedSuccess:             true,\n\t\t\tExpectedOutput:              \"[BODY].status (UP) == UP\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-numerical-equal\",\n\t\t\tCondition:       Condition(\"[BODY] == 123\"),\n\t\t\tResult:          &Result{Body: []byte(\"123\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY] == 123\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-numerical-less-than\",\n\t\t\tCondition:       Condition(\"[BODY] < 124\"),\n\t\t\tResult:          &Result{Body: []byte(\"123\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY] < 124\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-numerical-greater-than\",\n\t\t\tCondition:       Condition(\"[BODY] > 122\"),\n\t\t\tResult:          &Result{Body: []byte(\"123\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY] > 122\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-numerical-greater-than-failure\",\n\t\t\tCondition:       Condition(\"[BODY] > 123\"),\n\t\t\tResult:          &Result{Body: []byte(\"100\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY] (100) > 123\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath\",\n\t\t\tCondition:       Condition(\"[BODY].status == UP\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"status\\\":\\\"UP\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].status == UP\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-complex\",\n\t\t\tCondition:       Condition(\"[BODY].data.name == john\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": {\\\"id\\\": 1, \\\"name\\\": \\\"john\\\"}}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data.name == john\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-complex-invalid\",\n\t\t\tCondition:       Condition(\"[BODY].data.name == john\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": {\\\"id\\\": 1}}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY].data.name (INVALID) == john\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-complex-len-invalid\",\n\t\t\tCondition:       Condition(\"len([BODY].data.name) == john\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": {\\\"id\\\": 1}}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"len([BODY].data.name) (INVALID) == john\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-double-placeholder\",\n\t\t\tCondition:       Condition(\"[BODY].user.firstName != [BODY].user.lastName\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"user\\\": {\\\"firstName\\\": \\\"john\\\", \\\"lastName\\\": \\\"doe\\\"}}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].user.firstName != [BODY].user.lastName\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-double-placeholder-failure\",\n\t\t\tCondition:       Condition(\"[BODY].user.firstName == [BODY].user.lastName\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"user\\\": {\\\"firstName\\\": \\\"john\\\", \\\"lastName\\\": \\\"doe\\\"}}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY].user.firstName (john) == [BODY].user.lastName (doe)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-when-body-is-array\",\n\t\t\tCondition:       Condition(\"[BODY][0].id == 1\"),\n\t\t\tResult:          &Result{Body: []byte(\"[{\\\"id\\\": 1}, {\\\"id\\\": 2}]\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY][0].id == 1\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-when-body-has-null-parameter\",\n\t\t\tCondition:       Condition(\"[BODY].data == OK\"),\n\t\t\tResult:          &Result{Body: []byte(`{\"data\": null}\"`)},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY].data (INVALID) == OK\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-when-body-has-array-with-null\",\n\t\t\tCondition:       Condition(\"[BODY].items[0] == OK\"),\n\t\t\tResult:          &Result{Body: []byte(`{\"items\": [null, null]}\"`)},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY].items[0] (INVALID) == OK\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-when-body-is-null\",\n\t\t\tCondition:       Condition(\"[BODY].data == OK\"),\n\t\t\tResult:          &Result{Body: []byte(`null`)},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY].data (INVALID) == OK\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-when-body-is-array-but-actual-body-is-not\",\n\t\t\tCondition:       Condition(\"[BODY][0].name == test\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"statusCode\\\": 500, \\\"message\\\": \\\"Internal Server Error\\\"}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY][0].name (INVALID) == test\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-complex-int\",\n\t\t\tCondition:       Condition(\"[BODY].data.id == 1\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": {\\\"id\\\": 1}}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data.id == 1\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-complex-array-int\",\n\t\t\tCondition:       Condition(\"[BODY].data[1].id == 2\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": [{\\\"id\\\": 1}, {\\\"id\\\": 2}, {\\\"id\\\": 3}]}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data[1].id == 2\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-complex-int-using-greater-than\",\n\t\t\tCondition:       Condition(\"[BODY].data.id > 0\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": {\\\"id\\\": 1}}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data.id > 0\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-hexadecimal-int-using-greater-than\",\n\t\t\tCondition:       Condition(\"[BODY].data > 0\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0x1\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data > 0\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-hexadecimal-int-using-equal-to-0x1\",\n\t\t\tCondition:       Condition(\"[BODY].data == 1\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0x1\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == 1\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-hexadecimal-int-using-equals\",\n\t\t\tCondition:       Condition(\"[BODY].data == 0x1\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0x1\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == 0x1\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-hexadecimal-int-using-equal-to-0x2\",\n\t\t\tCondition:       Condition(\"[BODY].data == 2\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0x2\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == 2\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-hexadecimal-int-using-equal-to-0xF\",\n\t\t\tCondition:       Condition(\"[BODY].data == 15\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0xF\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == 15\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-hexadecimal-int-using-equal-to-0xC0ff33\",\n\t\t\tCondition:       Condition(\"[BODY].data == 12648243\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0xC0ff33\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == 12648243\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-hexadecimal-int-len\",\n\t\t\tCondition:       Condition(\"len([BODY].data) == 3\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0x1\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY].data) == 3\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-hexadecimal-int-greater\",\n\t\t\tCondition:       Condition(\"[BODY].data >= 1\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0x01\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data >= 1\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-hexadecimal-int-0x01-len\",\n\t\t\tCondition:       Condition(\"len([BODY].data) == 4\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0x01\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY].data) == 4\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-octal-int-using-greater-than\",\n\t\t\tCondition:       Condition(\"[BODY].data > 0\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0o1\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data > 0\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-octal-int-using-equal\",\n\t\t\tCondition:       Condition(\"[BODY].data == 2\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0o2\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == 2\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-octal-int-using-equals\",\n\t\t\tCondition:       Condition(\"[BODY].data == 0o2\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0o2\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == 0o2\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-binary-int-using-greater-than\",\n\t\t\tCondition:       Condition(\"[BODY].data > 0\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0b1\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data > 0\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-binary-int-using-equal\",\n\t\t\tCondition:       Condition(\"[BODY].data == 2\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0b0010\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == 2\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-binary-int-using-equals\",\n\t\t\tCondition:       Condition(\"[BODY].data == 0b10\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": \\\"0b0010\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == 0b10\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-complex-int-using-greater-than-failure\",\n\t\t\tCondition:       Condition(\"[BODY].data.id > 5\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": {\\\"id\\\": 1}}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY].data.id (1) > 5\",\n\t\t},\n\t\t{\n\t\t\tName:            \"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\n\t\t\tCondition:       Condition(\"[BODY].balance > 100\"),\n\t\t\tResult:          &Result{Body: []byte(`{\"balance\": \"123.40000000000005\"}`)},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].balance > 100\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-complex-int-using-less-than\",\n\t\t\tCondition:       Condition(\"[BODY].data.id < 5\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": {\\\"id\\\": 2}}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data.id < 5\",\n\t\t},\n\t\t{\n\t\t\tName:            \"body-jsonpath-complex-int-using-less-than-failure\",\n\t\t\tCondition:       Condition(\"[BODY].data.id < 5\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": {\\\"id\\\": 10}}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY].data.id (10) < 5\",\n\t\t},\n\t\t{\n\t\t\tName:            \"connected\",\n\t\t\tCondition:       Condition(\"[CONNECTED] == true\"),\n\t\t\tResult:          &Result{Connected: true},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[CONNECTED] == true\",\n\t\t},\n\t\t{\n\t\t\tName:            \"connected-failure\",\n\t\t\tCondition:       Condition(\"[CONNECTED] == true\"),\n\t\t\tResult:          &Result{Connected: false},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[CONNECTED] (false) == true\",\n\t\t},\n\t\t{\n\t\t\tName:            \"certificate-expiration-not-set\",\n\t\t\tCondition:       Condition(\"[CERTIFICATE_EXPIRATION] == 0\"),\n\t\t\tResult:          &Result{},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[CERTIFICATE_EXPIRATION] == 0\",\n\t\t},\n\t\t{\n\t\t\tName:            \"certificate-expiration-greater-than-numerical\",\n\t\t\tCondition:       Condition(\"[CERTIFICATE_EXPIRATION] > \" + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),\n\t\t\tResult:          &Result{CertificateExpiration: time.Hour * 24 * 60},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[CERTIFICATE_EXPIRATION] > 2419200000\",\n\t\t},\n\t\t{\n\t\t\tName:            \"certificate-expiration-greater-than-numerical-failure\",\n\t\t\tCondition:       Condition(\"[CERTIFICATE_EXPIRATION] > \" + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),\n\t\t\tResult:          &Result{CertificateExpiration: time.Hour * 24 * 14},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[CERTIFICATE_EXPIRATION] (336h) > 2419200000\",\n\t\t},\n\t\t{\n\t\t\tName:            \"certificate-expiration-greater-than-duration\",\n\t\t\tCondition:       Condition(\"[CERTIFICATE_EXPIRATION] > 12h\"),\n\t\t\tResult:          &Result{CertificateExpiration: 24 * time.Hour},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[CERTIFICATE_EXPIRATION] > 12h\",\n\t\t},\n\t\t{\n\t\t\tName:            \"certificate-expiration-greater-than-duration\",\n\t\t\tCondition:       Condition(\"[CERTIFICATE_EXPIRATION] > 48h\"),\n\t\t\tResult:          &Result{CertificateExpiration: 24 * time.Hour},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[CERTIFICATE_EXPIRATION] (24h) > 48h\",\n\t\t},\n\t\t{\n\t\t\tName:            \"no-placeholders\",\n\t\t\tCondition:       Condition(\"1 == 2\"),\n\t\t\tResult:          &Result{},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"1 == 2\",\n\t\t},\n\t\t///////////////\n\t\t// Functions //\n\t\t///////////////\n\t\t// len\n\t\t{\n\t\t\tName:            \"len-body-jsonpath-complex\",\n\t\t\tCondition:       Condition(\"len([BODY].data.name) == 4\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": {\\\"name\\\": \\\"john\\\"}}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY].data.name) == 4\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-array\",\n\t\t\tCondition:       Condition(\"len([BODY]) == 3\"),\n\t\t\tResult:          &Result{Body: []byte(\"[{\\\"id\\\": 1}, {\\\"id\\\": 2}, {\\\"id\\\": 3}]\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY]) == 3\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-keyed-array\",\n\t\t\tCondition:       Condition(\"len([BODY].data) == 3\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": [{\\\"id\\\": 1}, {\\\"id\\\": 2}, {\\\"id\\\": 3}]}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY].data) == 3\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-array-invalid\",\n\t\t\tCondition:       Condition(\"len([BODY].data) == 8\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"len([BODY].data) (INVALID) == 8\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-string\",\n\t\t\tCondition:       Condition(\"len([BODY]) == 8\"),\n\t\t\tResult:          &Result{Body: []byte(\"john.doe\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY]) == 8\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-keyed-string\",\n\t\t\tCondition:       Condition(\"len([BODY].name) == 8\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY].name) == 8\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-keyed-int\",\n\t\t\tCondition:       Condition(\"len([BODY].age) == 2\"),\n\t\t\tResult:          &Result{Body: []byte(`{\"age\":18}`)},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY].age) == 2\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-keyed-bool\",\n\t\t\tCondition:       Condition(\"len([BODY].adult) == 4\"),\n\t\t\tResult:          &Result{Body: []byte(`{\"adult\":true}`)},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY].adult) == 4\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-object-inside-array\",\n\t\t\tCondition:       Condition(\"len([BODY][0]) == 23\"),\n\t\t\tResult:          &Result{Body: []byte(`[{\"age\":18,\"adult\":true}]`)},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY][0]) == 23\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-object-keyed-int-inside-array\",\n\t\t\tCondition:       Condition(\"len([BODY][0].age) == 2\"),\n\t\t\tResult:          &Result{Body: []byte(`[{\"age\":18,\"adult\":true}]`)},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY][0].age) == 2\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-keyed-bool-inside-array\",\n\t\t\tCondition:       Condition(\"len([BODY][0].adult) == 4\"),\n\t\t\tResult:          &Result{Body: []byte(`[{\"age\":18,\"adult\":true}]`)},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY][0].adult) == 4\",\n\t\t},\n\t\t{\n\t\t\tName:            \"len-body-object\",\n\t\t\tCondition:       Condition(\"len([BODY]) == 20\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"len([BODY]) == 20\",\n\t\t},\n\t\t// pat\n\t\t{\n\t\t\tName:            \"pat-body-1\",\n\t\t\tCondition:       Condition(\"[BODY] == pat(*john*)\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY] == pat(*john*)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-body-2\",\n\t\t\tCondition:       Condition(\"[BODY].name == pat(john*)\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].name == pat(john*)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-body-failure\",\n\t\t\tCondition:       Condition(\"[BODY].name == pat(bob*)\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY].name (john.doe) == pat(bob*)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-body-html\",\n\t\t\tCondition:       Condition(\"[BODY] == pat(*<div id=\\\"user\\\">john.doe</div>*)\"),\n\t\t\tResult:          &Result{Body: []byte(`<!DOCTYPE html><html lang=\"en\"><head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" /></head><body><div id=\"user\">john.doe</div></body></html>`)},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY] == pat(*<div id=\\\"user\\\">john.doe</div>*)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-body-html-failure\",\n\t\t\tCondition:       Condition(\"[BODY] == pat(*<div id=\\\"user\\\">john.doe</div>*)\"),\n\t\t\tResult:          &Result{Body: []byte(`<!DOCTYPE html><html lang=\"en\"><head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" /></head><body><div id=\"user\">jane.doe</div></body></html>`)},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY] (<!DOCTYPE html><html lang...(truncated)) == pat(*<div id=\\\"user\\\">john.doe</div>*)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-body-html-failure-alt\",\n\t\t\tCondition:       Condition(\"pat(*<div id=\\\"user\\\">john.doe</div>*) == [BODY]\"),\n\t\t\tResult:          &Result{Body: []byte(`<!DOCTYPE html><html lang=\"en\"><head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" /></head><body><div id=\"user\">jane.doe</div></body></html>`)},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"pat(*<div id=\\\"user\\\">john.doe</div>*) == [BODY] (<!DOCTYPE html><html lang...(truncated))\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-body-in-array\",\n\t\t\tCondition:       Condition(\"[BODY].data == pat(*Whatever*)\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"data\\\": [\\\"hello\\\", \\\"world\\\", \\\"Whatever\\\"]}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].data == pat(*Whatever*)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-ip\",\n\t\t\tCondition:       Condition(\"[IP] == pat(10.*)\"),\n\t\t\tResult:          &Result{IP: \"10.0.0.0\"},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[IP] == pat(10.*)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-ip-failure\",\n\t\t\tCondition:       Condition(\"[IP] == pat(10.*)\"),\n\t\t\tResult:          &Result{IP: \"255.255.255.255\"},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[IP] (255.255.255.255) == pat(10.*)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-status\",\n\t\t\tCondition:       Condition(\"[STATUS] == pat(4*)\"),\n\t\t\tResult:          &Result{HTTPStatus: 404},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[STATUS] == pat(4*)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"pat-status-failure\",\n\t\t\tCondition:       Condition(\"[STATUS] == pat(4*)\"),\n\t\t\tResult:          &Result{HTTPStatus: 200},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[STATUS] (200) == pat(4*)\",\n\t\t},\n\t\t// any\n\t\t{\n\t\t\tName:            \"any-body-1\",\n\t\t\tCondition:       Condition(\"[BODY].name == any(john.doe, jane.doe)\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"name\\\": \\\"john.doe\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].name == any(john.doe, jane.doe)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"any-body-2\",\n\t\t\tCondition:       Condition(\"[BODY].name == any(john.doe, jane.doe)\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"name\\\": \\\"jane.doe\\\"}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[BODY].name == any(john.doe, jane.doe)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"any-body-failure\",\n\t\t\tCondition:       Condition(\"[BODY].name == any(john.doe, jane.doe)\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"name\\\": \\\"bob\\\"}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[BODY].name (bob) == any(john.doe, jane.doe)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"any-status-1\",\n\t\t\tCondition:       Condition(\"[STATUS] == any(200, 429)\"),\n\t\t\tResult:          &Result{HTTPStatus: 200},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[STATUS] == any(200, 429)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"any-status-2\",\n\t\t\tCondition:       Condition(\"[STATUS] == any(200, 429)\"),\n\t\t\tResult:          &Result{HTTPStatus: 429},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"[STATUS] == any(200, 429)\",\n\t\t},\n\t\t{\n\t\t\tName:            \"any-status-reverse\",\n\t\t\tCondition:       Condition(\"any(200, 429) == [STATUS]\"),\n\t\t\tResult:          &Result{HTTPStatus: 429},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"any(200, 429) == [STATUS]\",\n\t\t},\n\t\t{\n\t\t\tName:            \"any-status-failure\",\n\t\t\tCondition:       Condition(\"[STATUS] == any(200, 429)\"),\n\t\t\tResult:          &Result{HTTPStatus: 404},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"[STATUS] (404) == any(200, 429)\",\n\t\t},\n\t\t{\n\t\t\tName:                        \"any-status-failure-but-dont-resolve\",\n\t\t\tCondition:                   Condition(\"[STATUS] == any(200, 429)\"),\n\t\t\tResult:                      &Result{HTTPStatus: 404},\n\t\t\tDontResolveFailedConditions: true,\n\t\t\tExpectedSuccess:             false,\n\t\t\tExpectedOutput:              \"[STATUS] == any(200, 429)\",\n\t\t},\n\t\t// has\n\t\t{\n\t\t\tName:            \"has\",\n\t\t\tCondition:       Condition(\"has([BODY].errors) == false\"),\n\t\t\tResult:          &Result{Body: []byte(\"{}\")},\n\t\t\tExpectedSuccess: true,\n\t\t\tExpectedOutput:  \"has([BODY].errors) == false\",\n\t\t},\n\t\t{\n\t\t\tName:                        \"has-key-of-map\",\n\t\t\tCondition:                   Condition(\"has([BODY].article) == true\"),\n\t\t\tResult:                      &Result{Body: []byte(\"{\\n  \\\"article\\\": {\\n    \\\"id\\\": 123,\\n    \\\"title\\\": \\\"Hello, world!\\\",\\n    \\\"author\\\": \\\"John Doe\\\",\\n    \\\"tags\\\": [\\\"hello\\\", \\\"world\\\"],\\n    \\\"content\\\": \\\"I really like Gatus!\\\"\\n  }\\n}\")},\n\t\t\tDontResolveFailedConditions: false,\n\t\t\tExpectedSuccess:             true,\n\t\t\tExpectedOutput:              \"has([BODY].article) == true\",\n\t\t},\n\t\t{\n\t\t\tName:            \"has-failure\",\n\t\t\tCondition:       Condition(\"has([BODY].errors) == false\"),\n\t\t\tResult:          &Result{Body: []byte(\"{\\\"errors\\\": [\\\"1\\\"]}\")},\n\t\t\tExpectedSuccess: false,\n\t\t\tExpectedOutput:  \"has([BODY].errors) (true) == false\",\n\t\t},\n\t\t{\n\t\t\tName:                        \"has-failure-but-dont-resolve\",\n\t\t\tCondition:                   Condition(\"has([BODY].errors) == false\"),\n\t\t\tResult:                      &Result{Body: []byte(\"{\\\"errors\\\": [\\\"1\\\"]}\")},\n\t\t\tDontResolveFailedConditions: true,\n\t\t\tExpectedSuccess:             false,\n\t\t\tExpectedOutput:              \"has([BODY].errors) == false\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, scenario.ResolveSuccessfulConditions, nil)\n\t\t\tif scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {\n\t\t\t\tt.Errorf(\"Condition '%s' should have been success=%v\", scenario.Condition, scenario.ExpectedSuccess)\n\t\t\t}\n\t\t\tif scenario.Result.ConditionResults[0].Condition != scenario.ExpectedOutput {\n\t\t\t\tt.Errorf(\"Condition '%s' should have resolved to '%s', got '%s'\", scenario.Condition, scenario.ExpectedOutput, scenario.Result.ConditionResults[0].Condition)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCondition_evaluateWithInvalidOperator(t *testing.T) {\n\tcondition := Condition(\"[STATUS] ? 201\")\n\tresult := &Result{HTTPStatus: 201}\n\tcondition.evaluate(result, false, false, nil)\n\tif result.Success {\n\t\tt.Error(\"condition was invalid, result should've been a failure\")\n\t}\n\tif len(result.Errors) != 1 {\n\t\tt.Error(\"condition was invalid, result should've had an error\")\n\t}\n}\n\nfunc TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {\n\t// Test case: Suite endpoint with invalid context placeholder\n\t// This should display the original placeholder names with resolved values\n\tcondition := Condition(\"[STATUS] == [CONTEXT].expected_statusz\")\n\tresult := &Result{HTTPStatus: 200}\n\tctx := gontext.New(map[string]interface{}{\n\t\t// Note: expected_statusz is not in the context (typo - should be expected_status)\n\t\t\"expected_status\":   200,\n\t\t\"max_response_time\": 5000,\n\t})\n\t// Simulate suite endpoint evaluation with context\n\tsuccess := condition.evaluate(result, false, false, ctx) // false = don't skip resolution (default)\n\tif success {\n\t\tt.Error(\"Condition should have failed because [CONTEXT].expected_statusz doesn't exist\")\n\t}\n\tif len(result.ConditionResults) == 0 {\n\t\tt.Fatal(\"No condition results found\")\n\t}\n\tactualDisplay := result.ConditionResults[0].Condition\n\t// The expected format should preserve the placeholder names\n\texpectedDisplay := \"[STATUS] (200) == [CONTEXT].expected_statusz (INVALID)\"\n\tif actualDisplay != expectedDisplay {\n\t\tt.Errorf(\"Incorrect condition display for failed context placeholder\\nExpected: %s\\nActual:   %s\", expectedDisplay, actualDisplay)\n\t}\n}\n\nfunc TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {\n\t// Test case: Suite endpoint with valid context placeholder\n\tcondition := Condition(\"[STATUS] == [CONTEXT].expected_status\")\n\tresult := &Result{HTTPStatus: 200}\n\tctx := gontext.New(map[string]interface{}{\n\t\t\"expected_status\": 200,\n\t})\n\t// Simulate suite endpoint evaluation with context\n\tsuccess := condition.evaluate(result, false, false, ctx)\n\tif !success {\n\t\tt.Error(\"Condition should have succeeded\")\n\t}\n\tif len(result.ConditionResults) == 0 {\n\t\tt.Fatal(\"No condition results found\")\n\t}\n\tactualDisplay := result.ConditionResults[0].Condition\n\t// For successful conditions, just the original condition is shown\n\texpectedDisplay := \"[STATUS] == [CONTEXT].expected_status\"\n\tif actualDisplay != expectedDisplay {\n\t\tt.Errorf(\"Incorrect condition display for successful context placeholder\\nExpected: %s\\nActual:   %s\", expectedDisplay, actualDisplay)\n\t}\n}\n\nfunc TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {\n\t// Test case: One valid placeholder, one invalid\n\t// Note: For numerical comparisons, invalid placeholders that can't be parsed as numbers\n\t// default to 0 due to sanitizeAndResolveNumericalWithContext's behavior\n\tcondition := Condition(\"[RESPONSE_TIME] < [CONTEXT].invalid_key\")\n\tresult := &Result{Duration: 100 * 1000000} // 100ms in nanoseconds\n\tctx := gontext.New(map[string]interface{}{\n\t\t\"valid_key\": 5000,\n\t})\n\t// Simulate suite endpoint evaluation with context\n\tsuccess := condition.evaluate(result, false, false, ctx)\n\tif success {\n\t\tt.Error(\"Condition should have failed because [CONTEXT].invalid_key doesn't exist\")\n\t}\n\tif len(result.ConditionResults) == 0 {\n\t\tt.Fatal(\"No condition results found\")\n\t}\n\tactualDisplay := result.ConditionResults[0].Condition\n\t// For numerical comparisons, invalid context placeholders become 0\n\texpectedDisplay := \"[RESPONSE_TIME] (100) < [CONTEXT].invalid_key (0)\"\n\tif actualDisplay != expectedDisplay {\n\t\tt.Errorf(\"Incorrect condition display\\nExpected: %s\\nActual:   %s\", expectedDisplay, actualDisplay)\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/dns/dns.go",
    "content": "package dns\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/miekg/dns\"\n)\n\nvar (\n\t// ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name\n\tErrDNSWithNoQueryName = errors.New(\"you must specify a query name in the DNS configuration\")\n\n\t// ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type\n\tErrDNSWithInvalidQueryType = errors.New(\"invalid query type in the DNS configuration\")\n)\n\n// Config for an Endpoint of type DNS\ntype Config struct {\n\t// QueryType is the type for the DNS records like A, AAAA, CNAME...\n\tQueryType string `yaml:\"query-type\"`\n\n\t// QueryName is the query for DNS\n\tQueryName string `yaml:\"query-name\"`\n}\n\nfunc (d *Config) ValidateAndSetDefault() error {\n\tif len(d.QueryName) == 0 {\n\t\treturn ErrDNSWithNoQueryName\n\t}\n\tif !strings.HasSuffix(d.QueryName, \".\") {\n\t\td.QueryName += \".\"\n\t}\n\tif _, ok := dns.StringToType[d.QueryType]; !ok {\n\t\treturn ErrDNSWithInvalidQueryType\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "config/endpoint/dns/dns_test.go",
    "content": "package dns\n\nimport (\n\t\"testing\"\n)\n\nfunc TestConfig_ValidateAndSetDefault(t *testing.T) {\n\tdns := &Config{\n\t\tQueryType: \"A\",\n\t\tQueryName: \"\",\n\t}\n\terr := dns.ValidateAndSetDefault()\n\tif err == nil {\n\t\tt.Error(\"Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns\")\n\t}\n}\n\nfunc TestConfig_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) {\n\tdns := &Config{\n\t\tQueryType: \"B\",\n\t\tQueryName: \"example.com\",\n\t}\n\terr := dns.ValidateAndSetDefault()\n\tif err == nil {\n\t\tt.Error(\"Should've returned an error because endpoint's dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...\")\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/endpoint.go",
    "content": "package endpoint\n\nimport (\n\t\"bytes\"\n\t\"crypto/x509\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"maps\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/dns\"\n\tsshconfig \"github.com/TwiN/gatus/v5/config/endpoint/ssh\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/ui\"\n\t\"github.com/TwiN/gatus/v5/config/gontext\"\n\t\"github.com/TwiN/gatus/v5/config/key\"\n\t\"github.com/TwiN/gatus/v5/config/maintenance\"\n\t\"golang.org/x/crypto/ssh\"\n)\n\ntype Type string\n\nconst (\n\t// HostHeader is the name of the header used to specify the host\n\tHostHeader = \"Host\"\n\n\t// ContentTypeHeader is the name of the header used to specify the content type\n\tContentTypeHeader = \"Content-Type\"\n\n\t// UserAgentHeader is the name of the header used to specify the request's user agent\n\tUserAgentHeader = \"User-Agent\"\n\n\t// GatusUserAgent is the default user agent that Gatus uses to send requests.\n\tGatusUserAgent = \"Gatus/1.0\"\n\n\tTypeDNS      Type = \"DNS\"\n\tTypeTCP      Type = \"TCP\"\n\tTypeSCTP     Type = \"SCTP\"\n\tTypeUDP      Type = \"UDP\"\n\tTypeICMP     Type = \"ICMP\"\n\tTypeSTARTTLS Type = \"STARTTLS\"\n\tTypeTLS      Type = \"TLS\"\n\tTypeHTTP     Type = \"HTTP\"\n\tTypeGRPC     Type = \"GRPC\"\n\tTypeWS       Type = \"WEBSOCKET\"\n\tTypeSSH      Type = \"SSH\"\n\tTypeUNKNOWN  Type = \"UNKNOWN\"\n)\n\nvar (\n\t// ErrEndpointWithNoCondition is the error with which Gatus will panic if an endpoint is configured with no conditions\n\tErrEndpointWithNoCondition = errors.New(\"you must specify at least one condition per endpoint\")\n\n\t// ErrEndpointWithNoURL is the error with which Gatus will panic if an endpoint is configured with no url\n\tErrEndpointWithNoURL = errors.New(\"you must specify an url for each endpoint\")\n\n\t// ErrUnknownEndpointType is the error with which Gatus will panic if an endpoint has an unknown type\n\tErrUnknownEndpointType = errors.New(\"unknown endpoint type\")\n\n\t// ErrInvalidConditionFormat is the error with which Gatus will panic if a condition has an invalid format\n\tErrInvalidConditionFormat = errors.New(\"invalid condition format: does not match '<VALUE> <COMPARATOR> <VALUE>'\")\n\n\t// ErrInvalidEndpointIntervalForDomainExpirationPlaceholder is the error with which Gatus will panic if an endpoint\n\t// has both an interval smaller than 5 minutes and a condition with DomainExpirationPlaceholder.\n\t// This is because the free whois service we are using should not be abused, especially considering the fact that\n\t// the data takes a while to be updated.\n\tErrInvalidEndpointIntervalForDomainExpirationPlaceholder = errors.New(\"the minimum interval for an endpoint with a condition using the \" + DomainExpirationPlaceholder + \" placeholder is 300s (5m)\")\n)\n\n// Endpoint is the configuration of a service to be monitored\ntype Endpoint struct {\n\t// Enabled defines whether to enable the monitoring of the endpoint\n\tEnabled *bool `yaml:\"enabled,omitempty\"`\n\n\t// Name of the endpoint. Can be anything.\n\tName string `yaml:\"name\"`\n\n\t// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.\n\tGroup string `yaml:\"group,omitempty\"`\n\n\t// URL to send the request to\n\tURL string `yaml:\"url\"`\n\n\t// Method of the request made to the url of the endpoint\n\tMethod string `yaml:\"method,omitempty\"`\n\n\t// Body of the request\n\tBody string `yaml:\"body,omitempty\"`\n\n\t// GraphQL is whether to wrap the body in a query param ({\"query\":\"$body\"})\n\tGraphQL bool `yaml:\"graphql,omitempty\"`\n\n\t// Headers of the request\n\tHeaders map[string]string `yaml:\"headers,omitempty\"`\n\n\t// ExtraLabels are key-value pairs that can be used to metric the endpoint\n\tExtraLabels map[string]string `yaml:\"extra-labels,omitempty\"`\n\n\t// Interval is the duration to wait between every status check\n\tInterval time.Duration `yaml:\"interval,omitempty\"`\n\n\t// Conditions used to determine the health of the endpoint\n\tConditions []Condition `yaml:\"conditions\"`\n\n\t// Alerts is the alerting configuration for the endpoint in case of failure\n\tAlerts []*alert.Alert `yaml:\"alerts,omitempty\"`\n\n\t// MaintenanceWindow is the configuration for per-endpoint maintenance windows\n\tMaintenanceWindows []*maintenance.Config `yaml:\"maintenance-windows,omitempty\"`\n\n\t// DNSConfig is the configuration for DNS monitoring\n\tDNSConfig *dns.Config `yaml:\"dns,omitempty\"`\n\n\t// SSH is the configuration for SSH monitoring\n\tSSHConfig *sshconfig.Config `yaml:\"ssh,omitempty\"`\n\n\t// ClientConfig is the configuration of the client used to communicate with the endpoint's target\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n\n\t// UIConfig is the configuration for the UI\n\tUIConfig *ui.Config `yaml:\"ui,omitempty\"`\n\n\t// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row\n\tNumberOfFailuresInARow int `yaml:\"-\"`\n\n\t// NumberOfSuccessesInARow is the number of successful evaluations in a row\n\tNumberOfSuccessesInARow int `yaml:\"-\"`\n\n\t// LastReminderSent is the time at which the last reminder was sent for this endpoint.\n\tLastReminderSent time.Time `yaml:\"-\"`\n\n\t///////////////////////\n\t// SUITE-ONLY FIELDS //\n\t///////////////////////\n\n\t// Store is a map of values to extract from the result and store in the suite context\n\t// This field is only used when the endpoint is part of a suite\n\tStore map[string]string `yaml:\"store,omitempty\"`\n\n\t// AlwaysRun defines whether to execute this endpoint even if previous endpoints in the suite failed\n\t// This field is only used when the endpoint is part of a suite\n\tAlwaysRun bool `yaml:\"always-run,omitempty\"`\n}\n\n// IsEnabled returns whether the endpoint is enabled or not\nfunc (e *Endpoint) IsEnabled() bool {\n\tif e.Enabled == nil {\n\t\treturn true\n\t}\n\treturn *e.Enabled\n}\n\n// Type returns the endpoint type\nfunc (e *Endpoint) Type() Type {\n\tswitch {\n\tcase e.DNSConfig != nil:\n\t\treturn TypeDNS\n\tcase strings.HasPrefix(e.URL, \"tcp://\"):\n\t\treturn TypeTCP\n\tcase strings.HasPrefix(e.URL, \"sctp://\"):\n\t\treturn TypeSCTP\n\tcase strings.HasPrefix(e.URL, \"udp://\"):\n\t\treturn TypeUDP\n\tcase strings.HasPrefix(e.URL, \"icmp://\"):\n\t\treturn TypeICMP\n\tcase strings.HasPrefix(e.URL, \"starttls://\"):\n\t\treturn TypeSTARTTLS\n\tcase strings.HasPrefix(e.URL, \"tls://\"):\n\t\treturn TypeTLS\n\tcase strings.HasPrefix(e.URL, \"http://\") || strings.HasPrefix(e.URL, \"https://\"):\n\t\treturn TypeHTTP\n\tcase strings.HasPrefix(e.URL, \"grpc://\") || strings.HasPrefix(e.URL, \"grpcs://\"):\n\t\treturn TypeGRPC\n\tcase strings.HasPrefix(e.URL, \"ws://\") || strings.HasPrefix(e.URL, \"wss://\"):\n\t\treturn TypeWS\n\tcase strings.HasPrefix(e.URL, \"ssh://\"):\n\t\treturn TypeSSH\n\tdefault:\n\t\treturn TypeUNKNOWN\n\t}\n}\n\n// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one\nfunc (e *Endpoint) ValidateAndSetDefaults() error {\n\tif err := validateEndpointNameGroupAndAlerts(e.Name, e.Group, e.Alerts); err != nil {\n\t\treturn err\n\t}\n\tif len(e.URL) == 0 {\n\t\treturn ErrEndpointWithNoURL\n\t}\n\tif e.ClientConfig == nil {\n\t\te.ClientConfig = client.GetDefaultConfig()\n\t} else {\n\t\tif err := e.ClientConfig.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif e.UIConfig == nil {\n\t\te.UIConfig = ui.GetDefaultConfig()\n\t} else {\n\t\tif err := e.UIConfig.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif e.Interval == 0 {\n\t\te.Interval = 1 * time.Minute\n\t}\n\tif len(e.Method) == 0 {\n\t\te.Method = http.MethodGet\n\t}\n\tif len(e.Headers) == 0 {\n\t\te.Headers = make(map[string]string)\n\t}\n\t// Automatically add user agent header if there isn't one specified in the endpoint configuration\n\tif !hasHeader(e.Headers, UserAgentHeader) {\n\t\te.Headers[UserAgentHeader] = GatusUserAgent\n\t}\n\t// Automatically add \"Content-Type: application/json\" header if there's no Content-Type set\n\t// and endpoint.GraphQL is set to true\n\tif !hasHeader(e.Headers, ContentTypeHeader) && e.GraphQL {\n\t\te.Headers[ContentTypeHeader] = \"application/json\"\n\t}\n\tif len(e.Conditions) == 0 {\n\t\treturn ErrEndpointWithNoCondition\n\t}\n\tfor _, c := range e.Conditions {\n\t\tif e.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() {\n\t\t\treturn ErrInvalidEndpointIntervalForDomainExpirationPlaceholder\n\t\t}\n\t\tif err := c.Validate(); err != nil {\n\t\t\treturn fmt.Errorf(\"%v: %w\", ErrInvalidConditionFormat, err)\n\t\t}\n\t}\n\tif e.DNSConfig != nil {\n\t\treturn e.DNSConfig.ValidateAndSetDefault()\n\t}\n\tif e.SSHConfig != nil {\n\t\treturn e.SSHConfig.Validate()\n\t}\n\tif e.Type() == TypeUNKNOWN {\n\t\treturn ErrUnknownEndpointType\n\t}\n\tfor _, maintenanceWindow := range e.MaintenanceWindows {\n\t\tif err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t// Make sure that the request can be created\n\t_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody())))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// DisplayName returns an identifier made up of the Name and, if not empty, the Group.\nfunc (e *Endpoint) DisplayName() string {\n\tif len(e.Group) > 0 {\n\t\treturn e.Group + \"/\" + e.Name\n\t}\n\treturn e.Name\n}\n\n// Key returns the unique key for the Endpoint\nfunc (e *Endpoint) Key() string {\n\treturn key.ConvertGroupAndNameToKey(e.Group, e.Name)\n}\n\n// Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors\n// on configuration reload.\n// More context on https://github.com/TwiN/gatus/issues/536\nfunc (e *Endpoint) Close() {\n\tif e.Type() == TypeHTTP {\n\t\tclient.GetHTTPClient(e.ClientConfig).CloseIdleConnections()\n\t}\n}\n\n// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.\nfunc (e *Endpoint) EvaluateHealth() *Result {\n\treturn e.EvaluateHealthWithContext(nil)\n}\n\n// EvaluateHealthWithContext sends a request to the endpoint's URL with context support and evaluates the conditions\nfunc (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {\n\tresult := &Result{Success: true, Errors: []string{}}\n\t// Preprocess the endpoint with context if provided\n\tprocessedEndpoint := e\n\tif context != nil {\n\t\tprocessedEndpoint = e.preprocessWithContext(result, context)\n\t}\n\t// Parse or extract hostname from URL\n\tif processedEndpoint.DNSConfig != nil {\n\t\tresult.Hostname = strings.TrimSuffix(processedEndpoint.URL, \":53\")\n\t} else if processedEndpoint.Type() == TypeICMP {\n\t\t// To handle IPv6 addresses, we need to handle the hostname differently here. This is to avoid, for instance,\n\t\t// \"1111:2222:3333::4444\" being displayed as \"1111:2222:3333:\" because :4444 would be interpreted as a port.\n\t\tresult.Hostname = strings.TrimPrefix(processedEndpoint.URL, \"icmp://\")\n\t} else {\n\t\turlObject, err := url.Parse(processedEndpoint.URL)\n\t\tif err != nil {\n\t\t\tresult.AddError(err.Error())\n\t\t} else {\n\t\t\tresult.Hostname = urlObject.Hostname()\n\t\t\tresult.port = urlObject.Port()\n\t\t}\n\t}\n\t// Retrieve IP if necessary\n\tif processedEndpoint.needsToRetrieveIP() {\n\t\tprocessedEndpoint.getIP(result)\n\t}\n\t// Retrieve domain expiration if necessary\n\tif processedEndpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {\n\t\tvar err error\n\t\tif result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil {\n\t\t\tresult.AddError(err.Error())\n\t\t}\n\t}\n\t// Call the endpoint (if there's no errors)\n\tif len(result.Errors) == 0 {\n\t\tprocessedEndpoint.call(result)\n\t} else {\n\t\tresult.Success = false\n\t}\n\t// Evaluate the conditions\n\tfor _, condition := range processedEndpoint.Conditions {\n\t\tsuccess := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, processedEndpoint.UIConfig.ResolveSuccessfulConditions, context)\n\t\tif !success {\n\t\t\tresult.Success = false\n\t\t}\n\t}\n\tresult.Timestamp = time.Now()\n\t// Clean up parameters that we don't need to keep in the results\n\tif processedEndpoint.UIConfig.HideURL {\n\t\tfor errIdx, errorString := range result.Errors {\n\t\t\tresult.Errors[errIdx] = strings.ReplaceAll(errorString, processedEndpoint.URL, \"<redacted>\")\n\t\t}\n\t}\n\tif processedEndpoint.UIConfig.HideHostname {\n\t\tfor errIdx, errorString := range result.Errors {\n\t\t\tresult.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, \"<redacted>\")\n\t\t}\n\t\tresult.Hostname = \"\" // remove it from the result so it doesn't get exposed\n\t}\n\tif processedEndpoint.UIConfig.HidePort && len(result.port) > 0 {\n\t\tfor errIdx, errorString := range result.Errors {\n\t\t\tresult.Errors[errIdx] = strings.ReplaceAll(errorString, result.port, \"<redacted>\")\n\t\t}\n\t\tresult.port = \"\"\n\t}\n\tif processedEndpoint.UIConfig.HideErrors {\n\t\tresult.Errors = nil\n\t}\n\tif processedEndpoint.UIConfig.HideConditions {\n\t\tresult.ConditionResults = nil\n\t}\n\treturn result\n}\n\n// preprocessWithContext creates a copy of the endpoint with context placeholders replaced\nfunc (e *Endpoint) preprocessWithContext(result *Result, context *gontext.Gontext) *Endpoint {\n\t// Create a deep copy of the endpoint\n\tprocessed := &Endpoint{}\n\t*processed = *e\n\tvar err error\n\t// Replace context placeholders in URL\n\tif processed.URL, err = replaceContextPlaceholders(e.URL, context); err != nil {\n\t\tresult.AddError(err.Error())\n\t}\n\t// Replace context placeholders in Body\n\tif processed.Body, err = replaceContextPlaceholders(e.Body, context); err != nil {\n\t\tresult.AddError(err.Error())\n\t}\n\t// Replace context placeholders in Headers\n\tif e.Headers != nil {\n\t\tprocessed.Headers = make(map[string]string)\n\t\tfor k, v := range e.Headers {\n\t\t\tif processed.Headers[k], err = replaceContextPlaceholders(v, context); err != nil {\n\t\t\t\tresult.AddError(err.Error())\n\t\t\t}\n\t\t}\n\t}\n\treturn processed\n}\n\n// replaceContextPlaceholders replaces [CONTEXT].path placeholders with actual values\nfunc replaceContextPlaceholders(input string, ctx *gontext.Gontext) (string, error) {\n\tif ctx == nil {\n\t\treturn input, nil\n\t}\n\tvar contextErrors []string\n\tcontextRegex := regexp.MustCompile(`\\[CONTEXT\\]\\.[\\w\\.\\-]+`)\n\tresult := contextRegex.ReplaceAllStringFunc(input, func(match string) string {\n\t\t// Extract the path after [CONTEXT].\n\t\tpath := strings.TrimPrefix(match, \"[CONTEXT].\")\n\t\tvalue, err := ctx.Get(path)\n\t\tif err != nil {\n\t\t\tcontextErrors = append(contextErrors, fmt.Sprintf(\"path '%s' not found\", path))\n\t\t\treturn match // Keep placeholder for error reporting\n\t\t}\n\t\treturn fmt.Sprintf(\"%v\", value)\n\t})\n\tif len(contextErrors) > 0 {\n\t\treturn result, fmt.Errorf(\"context placeholder resolution failed: %s\", strings.Join(contextErrors, \", \"))\n\t}\n\treturn result, nil\n}\n\nfunc (e *Endpoint) getParsedBody() string {\n\tbody := e.Body\n\tbody = strings.ReplaceAll(body, \"[ENDPOINT_NAME]\", e.Name)\n\tbody = strings.ReplaceAll(body, \"[ENDPOINT_GROUP]\", e.Group)\n\tbody = strings.ReplaceAll(body, \"[ENDPOINT_URL]\", e.URL)\n\trandRegex, err := regexp.Compile(`\\[RANDOM_STRING_\\d+\\]`)\n\tif err == nil {\n\t\tbody = randRegex.ReplaceAllStringFunc(body, func(match string) string {\n\t\t\tn, _ := strconv.Atoi(match[15 : len(match)-1])\n\t\t\tif n > 8192 {\n\t\t\t\tn = 8192 // Limit the length of the random string to 8192 bytes to avoid excessive memory usage\n\t\t\t}\n\t\t\tconst availableCharacterBytes = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n\t\t\tb := make([]byte, n)\n\t\t\tfor i := range b {\n\t\t\t\tb[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]\n\t\t\t}\n\t\t\treturn string(b)\n\t\t})\n\t}\n\treturn body\n}\n\nfunc (e *Endpoint) getIP(result *Result) {\n\tif ips, err := net.LookupIP(result.Hostname); err != nil {\n\t\tresult.AddError(err.Error())\n\t\treturn\n\t} else {\n\t\tresult.IP = ips[0].String()\n\t}\n}\n\nfunc (e *Endpoint) call(result *Result) {\n\tvar request *http.Request\n\tvar response *http.Response\n\tvar err error\n\tvar certificate *x509.Certificate\n\tendpointType := e.Type()\n\tif endpointType == TypeHTTP {\n\t\trequest = e.buildHTTPRequest()\n\t}\n\tstartTime := time.Now()\n\tif endpointType == TypeDNS {\n\t\tresult.Connected, result.DNSRCode, result.Body, err = client.QueryDNS(e.DNSConfig.QueryType, e.DNSConfig.QueryName, e.URL)\n\t\tif err != nil {\n\t\t\tresult.AddError(err.Error())\n\t\t\treturn\n\t\t}\n\t\tresult.Duration = time.Since(startTime)\n\t} else if endpointType == TypeSTARTTLS || endpointType == TypeTLS {\n\t\tif endpointType == TypeSTARTTLS {\n\t\t\tresult.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, \"starttls://\"), e.ClientConfig)\n\t\t} else {\n\t\t\tresult.Connected, result.Body, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, \"tls://\"), e.getParsedBody(), e.ClientConfig)\n\t\t}\n\t\tif err != nil {\n\t\t\tresult.AddError(err.Error())\n\t\t\treturn\n\t\t}\n\t\tresult.Duration = time.Since(startTime)\n\t\tresult.CertificateExpiration = time.Until(certificate.NotAfter)\n\t} else if endpointType == TypeTCP {\n\t\tresult.Connected, result.Body = client.CanCreateNetworkConnection(\"tcp\", strings.TrimPrefix(e.URL, \"tcp://\"), e.getParsedBody(), e.ClientConfig)\n\t\tresult.Duration = time.Since(startTime)\n\t} else if endpointType == TypeUDP {\n\t\tresult.Connected, result.Body = client.CanCreateNetworkConnection(\"udp\", strings.TrimPrefix(e.URL, \"udp://\"), e.getParsedBody(), e.ClientConfig)\n\t\tresult.Duration = time.Since(startTime)\n\t} else if endpointType == TypeSCTP {\n\t\tresult.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, \"sctp://\"), e.ClientConfig)\n\t\tresult.Duration = time.Since(startTime)\n\t} else if endpointType == TypeICMP {\n\t\tresult.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, \"icmp://\"), e.ClientConfig)\n\t} else if endpointType == TypeWS {\n\t\twsHeaders := map[string]string{}\n\t\tif e.Headers != nil {\n\t\t\tmaps.Copy(wsHeaders, e.Headers)\n\t\t}\n\t\tif !hasHeader(wsHeaders, UserAgentHeader) {\n\t\t\twsHeaders[UserAgentHeader] = GatusUserAgent\n\t\t}\n\t\tresult.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)\n\t\tif err != nil {\n\t\t\tresult.AddError(err.Error())\n\t\t\treturn\n\t\t}\n\t\tresult.Duration = time.Since(startTime)\n\t} else if endpointType == TypeSSH {\n\t\t// If there's no username, password or private key specified, attempt to validate just the SSH banner\n\t\tif e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {\n\t\t\tresult.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, \"ssh://\"), e.ClientConfig)\n\t\t\tif err != nil {\n\t\t\t\tresult.AddError(err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresult.Success = result.Connected\n\t\t\tresult.Duration = time.Since(startTime)\n\t\t\treturn\n\t\t}\n\t\tvar cli *ssh.Client\n\t\tresult.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, \"ssh://\"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)\n\t\tif err != nil {\n\t\t\tresult.AddError(err.Error())\n\t\t\treturn\n\t\t}\n\t\tvar output []byte\n\t\tresult.Success, result.HTTPStatus, output, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)\n\t\tif err != nil {\n\t\t\tresult.AddError(err.Error())\n\t\t\treturn\n\t\t}\n\t\t// Only store the output in result.Body if there's a condition that uses the BodyPlaceholder\n\t\tif e.needsToReadBody() {\n\t\t\tresult.Body = output\n\t\t}\n\t\tresult.Duration = time.Since(startTime)\n\t} else if endpointType == TypeGRPC {\n\t\tuseTLS := strings.HasPrefix(e.URL, \"grpcs://\")\n\t\taddress := strings.TrimPrefix(strings.TrimPrefix(e.URL, \"grpcs://\"), \"grpc://\")\n\t\tconnected, status, err, duration := client.PerformGRPCHealthCheck(address, useTLS, e.ClientConfig)\n\t\tif err != nil {\n\t\t\tresult.AddError(err.Error())\n\t\t\treturn\n\t\t}\n\t\tresult.Connected = connected\n\t\tresult.Duration = duration\n\t\tif e.needsToReadBody() {\n\t\t\tresult.Body = []byte(fmt.Sprintf(\"{\\\"status\\\":\\\"%s\\\"}\", status))\n\t\t}\n\t} else {\n\t\tresponse, err = client.GetHTTPClient(e.ClientConfig).Do(request)\n\t\tresult.Duration = time.Since(startTime)\n\t\tif err != nil {\n\t\t\tresult.AddError(err.Error())\n\t\t\treturn\n\t\t}\n\t\tdefer response.Body.Close()\n\t\tif response.TLS != nil && len(response.TLS.PeerCertificates) > 0 {\n\t\t\tcertificate = response.TLS.PeerCertificates[0]\n\t\t\tresult.CertificateExpiration = time.Until(certificate.NotAfter)\n\t\t}\n\t\tresult.HTTPStatus = response.StatusCode\n\t\tresult.Connected = response.StatusCode > 0\n\t\t// Only read the Body if there's a condition that uses the BodyPlaceholder\n\t\tif e.needsToReadBody() {\n\t\t\tresult.Body, err = io.ReadAll(response.Body)\n\t\t\tif err != nil {\n\t\t\t\tresult.AddError(\"error reading response body:\" + err.Error())\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (e *Endpoint) buildHTTPRequest() *http.Request {\n\tvar bodyBuffer *bytes.Buffer\n\tif e.GraphQL {\n\t\tgraphQlBody := map[string]string{\n\t\t\t\"query\": e.getParsedBody(),\n\t\t}\n\t\tbody, _ := json.Marshal(graphQlBody)\n\t\tbodyBuffer = bytes.NewBuffer(body)\n\t} else {\n\t\tbodyBuffer = bytes.NewBuffer([]byte(e.getParsedBody()))\n\t}\n\trequest, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)\n\tfor k, v := range e.Headers {\n\t\trequest.Header.Set(k, v)\n\t\tif strings.EqualFold(k, HostHeader) {\n\t\t\trequest.Host = v\n\t\t}\n\t}\n\treturn request\n}\n\n// needsToReadBody checks if there's any condition or store mapping that requires the response Body to be read\nfunc (e *Endpoint) needsToReadBody() bool {\n\tfor _, condition := range e.Conditions {\n\t\tif condition.hasBodyPlaceholder() {\n\t\t\treturn true\n\t\t}\n\t}\n\t// Check store values for body placeholders\n\tif e.Store != nil {\n\t\tfor _, value := range e.Store {\n\t\t\tif strings.Contains(value, BodyPlaceholder) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed\nfunc (e *Endpoint) needsToRetrieveDomainExpiration() bool {\n\tfor _, condition := range e.Conditions {\n\t\tif condition.hasDomainExpirationPlaceholder() {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// needsToRetrieveIP checks if there's any condition that requires an IP lookup\nfunc (e *Endpoint) needsToRetrieveIP() bool {\n\tfor _, condition := range e.Conditions {\n\t\tif condition.hasIPPlaceholder() {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// hasHeader checks if a header exists in the map using a case-insensitive lookup\nfunc hasHeader(headers map[string]string, name string) bool {\n\tfor k := range headers {\n\t\tif strings.EqualFold(k, name) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "config/endpoint/endpoint_test.go",
    "content": "package endpoint\n\nimport (\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/dns\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/ssh\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/ui\"\n\t\"github.com/TwiN/gatus/v5/config/gontext\"\n\t\"github.com/TwiN/gatus/v5/config/maintenance\"\n\t\"github.com/TwiN/gatus/v5/test\"\n)\n\nfunc TestHasHeader(t *testing.T) {\n\tscenarios := []struct {\n\t\tname     string\n\t\theaders  map[string]string\n\t\tlookup   string\n\t\texpected bool\n\t}{\n\t\t{name: \"exact-match\", headers: map[string]string{\"User-Agent\": \"test\"}, lookup: \"User-Agent\", expected: true},\n\t\t{name: \"lowercase-lookup\", headers: map[string]string{\"User-Agent\": \"test\"}, lookup: \"user-agent\", expected: true},\n\t\t{name: \"uppercase-lookup\", headers: map[string]string{\"user-agent\": \"test\"}, lookup: \"USER-AGENT\", expected: true},\n\t\t{name: \"mixed-case\", headers: map[string]string{\"UsEr-AgEnT\": \"test\"}, lookup: \"uSeR-aGeNt\", expected: true},\n\t\t{name: \"not-found\", headers: map[string]string{\"Content-Type\": \"test\"}, lookup: \"User-Agent\", expected: false},\n\t\t{name: \"empty-headers\", headers: map[string]string{}, lookup: \"User-Agent\", expected: false},\n\t\t{name: \"nil-headers\", headers: nil, lookup: \"User-Agent\", expected: false},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tif result := hasHeader(scenario.headers, scenario.lookup); result != scenario.expected {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEndpoint(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tscenarios := []struct {\n\t\tName             string\n\t\tEndpoint         Endpoint\n\t\tExpectedResult   *Result\n\t\tMockRoundTripper test.MockRoundTripper\n\t}{\n\t\t{\n\t\t\tName: \"success\",\n\t\t\tEndpoint: Endpoint{\n\t\t\t\tName:       \"website-health\",\n\t\t\t\tURL:        \"https://twin.sh/health\",\n\t\t\t\tConditions: []Condition{\"[STATUS] == 200\", \"[BODY].status == UP\", \"[CERTIFICATE_EXPIRATION] > 24h\"},\n\t\t\t},\n\t\t\tExpectedResult: &Result{\n\t\t\t\tSuccess:   true,\n\t\t\t\tConnected: true,\n\t\t\t\tHostname:  \"twin.sh\",\n\t\t\t\tConditionResults: []*ConditionResult{\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: true},\n\t\t\t\t\t{Condition: \"[BODY].status == UP\", Success: true},\n\t\t\t\t\t{Condition: \"[CERTIFICATE_EXPIRATION] > 24h\", Success: true},\n\t\t\t\t},\n\t\t\t\tDomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.\n\t\t\t},\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tBody:       io.NopCloser(bytes.NewBufferString(`{\"status\": \"UP\"}`)),\n\t\t\t\t\tTLS:        &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(9999 * time.Hour)}}},\n\t\t\t\t}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName: \"failed-body-condition\",\n\t\t\tEndpoint: Endpoint{\n\t\t\t\tName:       \"website-health\",\n\t\t\t\tURL:        \"https://twin.sh/health\",\n\t\t\t\tConditions: []Condition{\"[STATUS] == 200\", \"[BODY].status == UP\"},\n\t\t\t},\n\t\t\tExpectedResult: &Result{\n\t\t\t\tSuccess:   false,\n\t\t\t\tConnected: true,\n\t\t\t\tHostname:  \"twin.sh\",\n\t\t\t\tConditionResults: []*ConditionResult{\n\t\t\t\t\t{Condition: \"[STATUS] == 200\", Success: true},\n\t\t\t\t\t{Condition: \"[BODY].status (DOWN) == UP\", Success: false},\n\t\t\t\t},\n\t\t\t\tDomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.\n\t\t\t},\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{\"status\": \"DOWN\"}`))}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName: \"failed-status-condition\",\n\t\t\tEndpoint: Endpoint{\n\t\t\t\tName:       \"website-health\",\n\t\t\t\tURL:        \"https://twin.sh/health\",\n\t\t\t\tConditions: []Condition{\"[STATUS] == 200\"},\n\t\t\t},\n\t\t\tExpectedResult: &Result{\n\t\t\t\tSuccess:   false,\n\t\t\t\tConnected: true,\n\t\t\t\tHostname:  \"twin.sh\",\n\t\t\t\tConditionResults: []*ConditionResult{\n\t\t\t\t\t{Condition: \"[STATUS] (502) == 200\", Success: false},\n\t\t\t\t},\n\t\t\t\tDomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.\n\t\t\t},\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName: \"failed-status-condition-with-hidden-conditions\",\n\t\t\tEndpoint: Endpoint{\n\t\t\t\tName:       \"website-health\",\n\t\t\t\tURL:        \"https://twin.sh/health\",\n\t\t\t\tConditions: []Condition{\"[STATUS] == 200\"},\n\t\t\t\tUIConfig:   &ui.Config{HideConditions: true},\n\t\t\t},\n\t\t\tExpectedResult: &Result{\n\t\t\t\tSuccess:          false,\n\t\t\t\tConnected:        true,\n\t\t\t\tHostname:         \"twin.sh\",\n\t\t\t\tConditionResults: []*ConditionResult{}, // Because UIConfig.HideConditions is true, the condition results should not be shown.\n\t\t\t\tDomainExpiration: 0,                    // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.\n\t\t\t},\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName: \"condition-with-failed-certificate-expiration\",\n\t\t\tEndpoint: Endpoint{\n\t\t\t\tName:       \"website-health\",\n\t\t\t\tURL:        \"https://twin.sh/health\",\n\t\t\t\tConditions: []Condition{\"[CERTIFICATE_EXPIRATION] > 100h\"},\n\t\t\t\tUIConfig:   &ui.Config{DontResolveFailedConditions: true},\n\t\t\t},\n\t\t\tExpectedResult: &Result{\n\t\t\t\tSuccess:   false,\n\t\t\t\tConnected: true,\n\t\t\t\tHostname:  \"twin.sh\",\n\t\t\t\tConditionResults: []*ConditionResult{\n\t\t\t\t\t// Because UIConfig.DontResolveFailedConditions is true, the values in the condition should not be resolved\n\t\t\t\t\t{Condition: \"[CERTIFICATE_EXPIRATION] > 100h\", Success: false},\n\t\t\t\t},\n\t\t\t\tDomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.\n\t\t\t},\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tBody:       http.NoBody,\n\t\t\t\t\tTLS:        &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(5 * time.Hour)}}},\n\t\t\t\t}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName: \"domain-expiration\",\n\t\t\tEndpoint: Endpoint{\n\t\t\t\tName:       \"website-health\",\n\t\t\t\tURL:        \"https://twin.sh/health\",\n\t\t\t\tConditions: []Condition{\"[DOMAIN_EXPIRATION] > 100h\"},\n\t\t\t\tInterval:   5 * time.Minute,\n\t\t\t},\n\t\t\tExpectedResult: &Result{\n\t\t\t\tSuccess:   true,\n\t\t\t\tConnected: true,\n\t\t\t\tHostname:  \"twin.sh\",\n\t\t\t\tConditionResults: []*ConditionResult{\n\t\t\t\t\t{Condition: \"[DOMAIN_EXPIRATION] > 100h\", Success: true},\n\t\t\t\t},\n\t\t\t\tDomainExpiration: 999999 * time.Hour, // Note that this test only checks if it's non-zero.\n\t\t\t},\n\t\t\tMockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t},\n\t\t{\n\t\t\tName: \"endpoint-that-will-time-out-and-hidden-hostname\",\n\t\t\tEndpoint: Endpoint{\n\t\t\t\tName:         \"endpoint-that-will-time-out\",\n\t\t\t\tURL:          \"https://twin.sh:9999/health\",\n\t\t\t\tConditions:   []Condition{\"[CONNECTED] == true\"},\n\t\t\t\tUIConfig:     &ui.Config{HideHostname: true, HidePort: true},\n\t\t\t\tClientConfig: &client.Config{Timeout: time.Millisecond},\n\t\t\t},\n\t\t\tExpectedResult: &Result{\n\t\t\t\tSuccess:   false,\n\t\t\t\tConnected: false,\n\t\t\t\tHostname:  \"\", // Because Endpoint.UIConfig.HideHostname is true, this should be empty.\n\t\t\t\tConditionResults: []*ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] (false) == true\", Success: false},\n\t\t\t\t},\n\t\t\t\t// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.\n\t\t\t\tDomainExpiration: 0,\n\t\t\t\t// Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by <redacted>.\n\t\t\t\tErrors: []string{`Get \"https://<redacted>:<redacted>/health\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},\n\t\t\t},\n\t\t\tMockRoundTripper: nil,\n\t\t},\n\t\t{\n\t\t\tName: \"endpoint-that-will-time-out-and-hidden-url\",\n\t\t\tEndpoint: Endpoint{\n\t\t\t\tName:         \"endpoint-that-will-time-out\",\n\t\t\t\tURL:          \"https://twin.sh/health\",\n\t\t\t\tConditions:   []Condition{\"[CONNECTED] == true\"},\n\t\t\t\tUIConfig:     &ui.Config{HideURL: true},\n\t\t\t\tClientConfig: &client.Config{Timeout: time.Millisecond},\n\t\t\t},\n\t\t\tExpectedResult: &Result{\n\t\t\t\tSuccess:   false,\n\t\t\t\tConnected: false,\n\t\t\t\tHostname:  \"twin.sh\",\n\t\t\t\tConditionResults: []*ConditionResult{\n\t\t\t\t\t{Condition: \"[CONNECTED] (false) == true\", Success: false},\n\t\t\t\t},\n\t\t\t\t// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.\n\t\t\t\tDomainExpiration: 0,\n\t\t\t\t// Because Endpoint.UIConfig.HideURL is true, the URL should be replaced by <redacted>.\n\t\t\t\tErrors: []string{`Get \"<redacted>\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},\n\t\t\t},\n\t\t\tMockRoundTripper: nil,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tif scenario.MockRoundTripper != nil {\n\t\t\t\tmockClient := &http.Client{Transport: scenario.MockRoundTripper}\n\t\t\t\tif scenario.Endpoint.ClientConfig != nil && scenario.Endpoint.ClientConfig.Timeout > 0 {\n\t\t\t\t\tmockClient.Timeout = scenario.Endpoint.ClientConfig.Timeout\n\t\t\t\t}\n\t\t\t\tclient.InjectHTTPClient(mockClient)\n\t\t\t} else {\n\t\t\t\tclient.InjectHTTPClient(nil)\n\t\t\t}\n\t\t\terr := scenario.Endpoint.ValidateAndSetDefaults()\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"did not expect an error, got\", err)\n\t\t\t}\n\t\t\tresult := scenario.Endpoint.EvaluateHealth()\n\t\t\tif result.Success != scenario.ExpectedResult.Success {\n\t\t\t\tt.Errorf(\"Expected success to be %v, got %v\", scenario.ExpectedResult.Success, result.Success)\n\t\t\t}\n\t\t\tif result.Connected != scenario.ExpectedResult.Connected {\n\t\t\t\tt.Errorf(\"Expected connected to be %v, got %v\", scenario.ExpectedResult.Connected, result.Connected)\n\t\t\t}\n\t\t\tif result.Hostname != scenario.ExpectedResult.Hostname {\n\t\t\t\tt.Errorf(\"Expected hostname to be %v, got %v\", scenario.ExpectedResult.Hostname, result.Hostname)\n\t\t\t}\n\t\t\tif len(result.ConditionResults) != len(scenario.ExpectedResult.ConditionResults) {\n\t\t\t\tt.Errorf(\"Expected %v condition results, got %v\", len(scenario.ExpectedResult.ConditionResults), len(result.ConditionResults))\n\t\t\t} else {\n\t\t\t\tfor i, conditionResult := range result.ConditionResults {\n\t\t\t\t\tif conditionResult.Condition != scenario.ExpectedResult.ConditionResults[i].Condition {\n\t\t\t\t\t\tt.Errorf(\"Expected condition to be %v, got %v\", scenario.ExpectedResult.ConditionResults[i].Condition, conditionResult.Condition)\n\t\t\t\t\t}\n\t\t\t\t\tif conditionResult.Success != scenario.ExpectedResult.ConditionResults[i].Success {\n\t\t\t\t\t\tt.Errorf(\"Expected success of condition '%s' to be %v, got %v\", conditionResult.Condition, scenario.ExpectedResult.ConditionResults[i].Success, conditionResult.Success)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(result.Errors) != len(scenario.ExpectedResult.Errors) {\n\t\t\t\tt.Errorf(\"Expected %v errors, got %v\", len(scenario.ExpectedResult.Errors), len(result.Errors))\n\t\t\t} else {\n\t\t\t\tfor i, err := range result.Errors {\n\t\t\t\t\tif err != scenario.ExpectedResult.Errors[i] {\n\t\t\t\t\t\tt.Errorf(\"Expected error to be %v, got %v\", scenario.ExpectedResult.Errors[i], err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif result.DomainExpiration != scenario.ExpectedResult.DomainExpiration {\n\t\t\t\t// Note that DomainExpiration is only resolved if there's a condition with the DomainExpirationPlaceholder in it.\n\t\t\t\t// In other words, if there's no condition with [DOMAIN_EXPIRATION] in it, the DomainExpiration field will be 0.\n\t\t\t\t// Because this is a live call, mocking it would be too much of a pain, so we're just going to check if\n\t\t\t\t// the actual value is non-zero when the expected result is non-zero.\n\t\t\t\tif scenario.ExpectedResult.DomainExpiration.Hours() > 0 && !(result.DomainExpiration.Hours() > 0) {\n\t\t\t\t\tt.Errorf(\"Expected domain expiration to be non-zero, got %v\", result.DomainExpiration)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEndpoint_ResolveSuccessfulConditions(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\tendpoint := Endpoint{\n\t\tName:       \"test-endpoint\",\n\t\tURL:        \"https://example.com/health\",\n\t\tConditions: []Condition{\"[BODY].status == UP\"},\n\t\tUIConfig:   &ui.Config{ResolveSuccessfulConditions: true},\n\t}\n\tmockResponse := test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\treturn &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{\"status\":\"UP\"}`))}\n\t})\n\tclient.InjectHTTPClient(&http.Client{Transport: mockResponse})\n\tif err := endpoint.ValidateAndSetDefaults(); err != nil {\n\t\tt.Fatalf(\"ValidateAndSetDefaults failed: %v\", err)\n\t}\n\tresult := endpoint.EvaluateHealth()\n\tif len(result.ConditionResults) != 1 {\n\t\tt.Fatalf(\"expected 1 condition result, got %d\", len(result.ConditionResults))\n\t}\n\texpectedCondition := \"[BODY].status (UP) == UP\"\n\tif result.ConditionResults[0].Condition != expectedCondition {\n\t\tt.Errorf(\"expected condition to be '%s', got '%s'\", expectedCondition, result.ConditionResults[0].Condition)\n\t}\n}\n\nfunc TestEndpoint_IsEnabled(t *testing.T) {\n\tif !(&Endpoint{Enabled: nil}).IsEnabled() {\n\t\tt.Error(\"endpoint.IsEnabled() should've returned true, because Enabled was set to nil\")\n\t}\n\tif value := false; (&Endpoint{Enabled: &value}).IsEnabled() {\n\t\tt.Error(\"endpoint.IsEnabled() should've returned false, because Enabled was set to false\")\n\t}\n\tif value := true; !(&Endpoint{Enabled: &value}).IsEnabled() {\n\t\tt.Error(\"Endpoint.IsEnabled() should've returned true, because Enabled was set to true\")\n\t}\n}\n\nfunc TestEndpoint_Type(t *testing.T) {\n\ttype args struct {\n\t\tURL string\n\t\tDNS *dns.Config\n\t\tSSH *ssh.Config\n\t}\n\ttests := []struct {\n\t\targs args\n\t\twant Type\n\t}{\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"8.8.8.8\",\n\t\t\t\tDNS: &dns.Config{\n\t\t\t\t\tQueryType: \"A\",\n\t\t\t\t\tQueryName: \"example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: TypeDNS,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"tcp://127.0.0.1:6379\",\n\t\t\t},\n\t\t\twant: TypeTCP,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"icmp://example.com\",\n\t\t\t},\n\t\t\twant: TypeICMP,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"sctp://example.com\",\n\t\t\t},\n\t\t\twant: TypeSCTP,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"udp://example.com\",\n\t\t\t},\n\t\t\twant: TypeUDP,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"starttls://smtp.gmail.com:587\",\n\t\t\t},\n\t\t\twant: TypeSTARTTLS,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"tls://example.com:443\",\n\t\t\t},\n\t\t\twant: TypeTLS,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"https://twin.sh/health\",\n\t\t\t},\n\t\t\twant: TypeHTTP,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"wss://example.com/\",\n\t\t\t},\n\t\t\twant: TypeWS,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"ws://example.com/\",\n\t\t\t},\n\t\t\twant: TypeWS,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"ssh://example.com:22\",\n\t\t\t\tSSH: &ssh.Config{\n\t\t\t\t\tUsername: \"root\",\n\t\t\t\t\tPassword: \"password\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: TypeSSH,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"invalid://example.org\",\n\t\t\t},\n\t\t\twant: TypeUNKNOWN,\n\t\t},\n\t\t{\n\t\t\targs: args{\n\t\t\t\tURL: \"no-scheme\",\n\t\t\t},\n\t\t\twant: TypeUNKNOWN,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(string(tt.want), func(t *testing.T) {\n\t\t\tendpoint := Endpoint{\n\t\t\t\tURL:       tt.args.URL,\n\t\t\t\tDNSConfig: tt.args.DNS,\n\t\t\t}\n\t\t\tif got := endpoint.Type(); got != tt.want {\n\t\t\t\tt.Errorf(\"Endpoint.Type() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEndpoint_ValidateAndSetDefaults(t *testing.T) {\n\tendpoint := Endpoint{\n\t\tName:               \"website-health\",\n\t\tURL:                \"https://twin.sh/health\",\n\t\tConditions:         []Condition{Condition(\"[STATUS] == 200\")},\n\t\tAlerts:             []*alert.Alert{{Type: alert.TypePagerDuty}},\n\t\tMaintenanceWindows: []*maintenance.Config{{Start: \"03:50\", Duration: 4 * time.Hour}},\n\t}\n\tif err := endpoint.ValidateAndSetDefaults(); err != nil {\n\t\tt.Errorf(\"Expected no error, got %v\", err)\n\t}\n\tif endpoint.ClientConfig == nil {\n\t\tt.Error(\"client configuration should've been set to the default configuration\")\n\t} else {\n\t\tif endpoint.ClientConfig.Insecure != client.GetDefaultConfig().Insecure {\n\t\t\tt.Errorf(\"Default client configuration should've set Insecure to %v, got %v\", client.GetDefaultConfig().Insecure, endpoint.ClientConfig.Insecure)\n\t\t}\n\t\tif endpoint.ClientConfig.IgnoreRedirect != client.GetDefaultConfig().IgnoreRedirect {\n\t\t\tt.Errorf(\"Default client configuration should've set IgnoreRedirect to %v, got %v\", client.GetDefaultConfig().IgnoreRedirect, endpoint.ClientConfig.IgnoreRedirect)\n\t\t}\n\t\tif endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {\n\t\t\tt.Errorf(\"Default client configuration should've set Timeout to %v, got %v\", client.GetDefaultConfig().Timeout, endpoint.ClientConfig.Timeout)\n\t\t}\n\t}\n\tif endpoint.Method != \"GET\" {\n\t\tt.Error(\"Endpoint method should've defaulted to GET\")\n\t}\n\tif endpoint.Interval != time.Minute {\n\t\tt.Error(\"Endpoint interval should've defaulted to 1 minute\")\n\t}\n\tif endpoint.Headers == nil {\n\t\tt.Error(\"Endpoint headers should've defaulted to an empty map\")\n\t}\n\tif len(endpoint.Alerts) != 1 {\n\t\tt.Error(\"Endpoint should've had 1 alert\")\n\t}\n\tif !endpoint.Alerts[0].IsEnabled() {\n\t\tt.Error(\"Endpoint alert should've defaulted to true\")\n\t}\n\tif endpoint.Alerts[0].SuccessThreshold != 2 {\n\t\tt.Error(\"Endpoint alert should've defaulted to a success threshold of 2\")\n\t}\n\tif endpoint.Alerts[0].FailureThreshold != 3 {\n\t\tt.Error(\"Endpoint alert should've defaulted to a failure threshold of 3\")\n\t}\n\tif len(endpoint.MaintenanceWindows) != 1 {\n\t\tt.Error(\"Endpoint should've had 1 maintenance window\")\n\t}\n\tif !endpoint.MaintenanceWindows[0].IsEnabled() {\n\t\tt.Error(\"Endpoint maintenance should've defaulted to true\")\n\t}\n\tif endpoint.MaintenanceWindows[0].Timezone != \"UTC\" {\n\t\tt.Error(\"Endpoint maintenance should've defaulted to UTC\")\n\t}\n}\n\nfunc TestEndpoint_ValidateAndSetDefaultsWithInvalidCondition(t *testing.T) {\n\tendpoint := Endpoint{\n\t\tName:       \"invalid-condition\",\n\t\tURL:        \"https://twin.sh/health\",\n\t\tConditions: []Condition{\"[STATUS] invalid 200\"},\n\t}\n\tif err := endpoint.ValidateAndSetDefaults(); err == nil {\n\t\tt.Error(\"endpoint validation should've returned an error, but didn't\")\n\t}\n}\n\nfunc TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {\n\tendpoint := Endpoint{\n\t\tName:       \"website-health\",\n\t\tURL:        \"https://twin.sh/health\",\n\t\tConditions: []Condition{Condition(\"[STATUS] == 200\")},\n\t\tClientConfig: &client.Config{\n\t\t\tInsecure:       true,\n\t\t\tIgnoreRedirect: true,\n\t\t\tTimeout:        0,\n\t\t},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\tif endpoint.ClientConfig == nil {\n\t\tt.Error(\"client configuration should've been set to the default configuration\")\n\t} else {\n\t\tif !endpoint.ClientConfig.Insecure {\n\t\t\tt.Error(\"endpoint.ClientConfig.Insecure should've been set to true\")\n\t\t}\n\t\tif !endpoint.ClientConfig.IgnoreRedirect {\n\t\t\tt.Error(\"endpoint.ClientConfig.IgnoreRedirect should've been set to true\")\n\t\t}\n\t\tif endpoint.ClientConfig.Timeout != client.GetDefaultConfig().Timeout {\n\t\t\tt.Error(\"endpoint.ClientConfig.Timeout should've been set to 10s, because the timeout value entered is not set or invalid\")\n\t\t}\n\t}\n}\n\nfunc TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {\n\tendpoint := &Endpoint{\n\t\tName: \"dns-test\",\n\t\tURL:  \"https://example.com\",\n\t\tDNSConfig: &dns.Config{\n\t\t\tQueryType: \"A\",\n\t\t\tQueryName: \"example.com\",\n\t\t},\n\t\tConditions: []Condition{Condition(\"[DNS_RCODE] == NOERROR\")},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Error(\"did not expect an error, got\", err)\n\t}\n\tif endpoint.DNSConfig.QueryName != \"example.com.\" {\n\t\tt.Error(\"Endpoint.dns.query-name should be formatted with . suffix\")\n\t}\n}\n\nfunc TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {\n\tscenarios := []struct {\n\t\tname        string\n\t\tusername    string\n\t\tpassword    string\n\t\tprivateKey  string\n\t\texpectedErr error\n\t}{\n\t\t{\n\t\t\tname:        \"fail when has no user but has password\",\n\t\t\tusername:    \"\",\n\t\t\tpassword:    \"password\",\n\t\t\texpectedErr: ssh.ErrEndpointWithoutSSHUsername,\n\t\t},\n\t\t{\n\t\t\tname:        \"fail when has no user but has private key\",\n\t\t\tusername:    \"\",\n\t\t\tprivateKey:  \"-----BEGIN\",\n\t\t\texpectedErr: ssh.ErrEndpointWithoutSSHUsername,\n\t\t},\n\t\t{\n\t\t\tname:        \"fail when has no password or private key\",\n\t\t\tusername:    \"username\",\n\t\t\tpassword:    \"\",\n\t\t\tprivateKey:  \"\",\n\t\t\texpectedErr: ssh.ErrEndpointWithoutSSHAuth,\n\t\t},\n\t\t{\n\t\t\tname:        \"success when username and password are set\",\n\t\t\tusername:    \"username\",\n\t\t\tpassword:    \"password\",\n\t\t\texpectedErr: nil,\n\t\t},\n\t\t{\n\t\t\tname:        \"success when username and private key are set\",\n\t\t\tusername:    \"username\",\n\t\t\tprivateKey:  \"-----BEGIN\",\n\t\t\texpectedErr: nil,\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tendpoint := &Endpoint{\n\t\t\t\tName: \"ssh-test\",\n\t\t\t\tURL:  \"https://example.com\",\n\t\t\t\tSSHConfig: &ssh.Config{\n\t\t\t\t\tUsername:   scenario.username,\n\t\t\t\t\tPassword:   scenario.password,\n\t\t\t\t\tPrivateKey: scenario.privateKey,\n\t\t\t\t},\n\t\t\t\tConditions: []Condition{Condition(\"[STATUS] == 0\")},\n\t\t\t}\n\t\t\terr := endpoint.ValidateAndSetDefaults()\n\t\t\tif !errors.Is(err, scenario.expectedErr) {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.expectedErr, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) {\n\tscenarios := []struct {\n\t\tendpoint    *Endpoint\n\t\texpectedErr error\n\t}{\n\t\t{\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tName:       \"\",\n\t\t\t\tURL:        \"https://example.com\",\n\t\t\t\tConditions: []Condition{Condition(\"[STATUS] == 200\")},\n\t\t\t},\n\t\t\texpectedErr: ErrEndpointWithNoName,\n\t\t},\n\t\t{\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tName:       \"endpoint-with-no-url\",\n\t\t\t\tURL:        \"\",\n\t\t\t\tConditions: []Condition{Condition(\"[STATUS] == 200\")},\n\t\t\t},\n\t\t\texpectedErr: ErrEndpointWithNoURL,\n\t\t},\n\t\t{\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tName:       \"endpoint-with-no-conditions\",\n\t\t\t\tURL:        \"https://example.com\",\n\t\t\t\tConditions: nil,\n\t\t\t},\n\t\t\texpectedErr: ErrEndpointWithNoCondition,\n\t\t},\n\t\t{\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tName:       \"domain-expiration-with-bad-interval\",\n\t\t\t\tURL:        \"https://example.com\",\n\t\t\t\tInterval:   time.Minute,\n\t\t\t\tConditions: []Condition{Condition(\"[DOMAIN_EXPIRATION] > 720h\")},\n\t\t\t},\n\t\t\texpectedErr: ErrInvalidEndpointIntervalForDomainExpirationPlaceholder,\n\t\t},\n\t\t{\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tName:       \"domain-expiration-with-good-interval\",\n\t\t\t\tURL:        \"https://example.com\",\n\t\t\t\tInterval:   5 * time.Minute,\n\t\t\t\tConditions: []Condition{Condition(\"[DOMAIN_EXPIRATION] > 720h\")},\n\t\t\t},\n\t\t\texpectedErr: nil,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.endpoint.Name, func(t *testing.T) {\n\t\t\tif err := scenario.endpoint.ValidateAndSetDefaults(); err != scenario.expectedErr {\n\t\t\t\tt.Errorf(\"Expected error %v, got %v\", scenario.expectedErr, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEndpoint_buildHTTPRequest(t *testing.T) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tendpoint := Endpoint{\n\t\tName:       \"website-health\",\n\t\tURL:        \"https://twin.sh/health\",\n\t\tConditions: []Condition{condition},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\trequest := endpoint.buildHTTPRequest()\n\tif request.Method != \"GET\" {\n\t\tt.Error(\"request.Method should've been GET, but was\", request.Method)\n\t}\n\tif request.Host != \"twin.sh\" {\n\t\tt.Error(\"request.Host should've been twin.sh, but was\", request.Host)\n\t}\n\tif userAgent := request.Header.Get(\"User-Agent\"); userAgent != GatusUserAgent {\n\t\tt.Errorf(\"request.Header.Get(User-Agent) should've been %s, but was %s\", GatusUserAgent, userAgent)\n\t}\n}\n\nfunc TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tendpoint := Endpoint{\n\t\tName:       \"website-health\",\n\t\tURL:        \"https://twin.sh/health\",\n\t\tConditions: []Condition{condition},\n\t\tHeaders: map[string]string{\n\t\t\t\"User-Agent\": \"Test/2.0\",\n\t\t},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\trequest := endpoint.buildHTTPRequest()\n\tif request.Method != \"GET\" {\n\t\tt.Error(\"request.Method should've been GET, but was\", request.Method)\n\t}\n\tif request.Host != \"twin.sh\" {\n\t\tt.Error(\"request.Host should've been twin.sh, but was\", request.Host)\n\t}\n\tif userAgent := request.Header.Get(\"User-Agent\"); userAgent != \"Test/2.0\" {\n\t\tt.Errorf(\"request.Header.Get(User-Agent) should've been %s, but was %s\", \"Test/2.0\", userAgent)\n\t}\n}\n\nfunc TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tendpoint := Endpoint{\n\t\tName:       \"website-health\",\n\t\tURL:        \"https://twin.sh/health\",\n\t\tMethod:     \"POST\",\n\t\tConditions: []Condition{condition},\n\t\tHeaders: map[string]string{\n\t\t\t\"Host\": \"example.com\",\n\t\t},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\trequest := endpoint.buildHTTPRequest()\n\tif request.Method != \"POST\" {\n\t\tt.Error(\"request.Method should've been POST, but was\", request.Method)\n\t}\n\tif request.Host != \"example.com\" {\n\t\tt.Error(\"request.Host should've been example.com, but was\", request.Host)\n\t}\n}\n\nfunc TestEndpoint_buildHTTPRequestWithLowercaseUserAgent(t *testing.T) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tendpoint := Endpoint{\n\t\tName:       \"website-health\",\n\t\tURL:        \"https://twin.sh/health\",\n\t\tConditions: []Condition{condition},\n\t\tHeaders: map[string]string{\n\t\t\t\"user-agent\": \"CustomAgent/1.0\",\n\t\t},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\tif _, exists := endpoint.Headers[UserAgentHeader]; exists {\n\t\tt.Error(\"User-Agent header should not have been added since user-agent was already specified\")\n\t}\n\trequest := endpoint.buildHTTPRequest()\n\tif userAgent := request.Header.Get(\"User-Agent\"); userAgent != \"CustomAgent/1.0\" {\n\t\tt.Errorf(\"request.Header.Get(User-Agent) should've been CustomAgent/1.0, but was %s\", userAgent)\n\t}\n}\n\nfunc TestEndpoint_buildHTTPRequestWithLowercaseContentType(t *testing.T) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tendpoint := Endpoint{\n\t\tName:       \"website-graphql\",\n\t\tURL:        \"https://twin.sh/graphql\",\n\t\tMethod:     \"POST\",\n\t\tConditions: []Condition{condition},\n\t\tGraphQL:    true,\n\t\tHeaders: map[string]string{\n\t\t\t\"content-type\": \"application/graphql\",\n\t\t},\n\t\tBody: `{ users { id } }`,\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\tif _, exists := endpoint.Headers[ContentTypeHeader]; exists {\n\t\tt.Error(\"Content-Type header should not have been added since content-type was already specified\")\n\t}\n\trequest := endpoint.buildHTTPRequest()\n\tif contentType := request.Header.Get(\"Content-Type\"); contentType != \"application/graphql\" {\n\t\tt.Errorf(\"request.Header.Get(Content-Type) should've been application/graphql, but was %s\", contentType)\n\t}\n}\n\nfunc TestEndpoint_buildHTTPRequestWithLowercaseHostHeader(t *testing.T) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tendpoint := Endpoint{\n\t\tName:       \"website-health\",\n\t\tURL:        \"https://twin.sh/health\",\n\t\tMethod:     \"POST\",\n\t\tConditions: []Condition{condition},\n\t\tHeaders: map[string]string{\n\t\t\t\"host\": \"example.com\",\n\t\t},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\trequest := endpoint.buildHTTPRequest()\n\tif request.Host != \"example.com\" {\n\t\tt.Error(\"request.Host should've been example.com, but was\", request.Host)\n\t}\n}\n\nfunc TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tendpoint := Endpoint{\n\t\tName:       \"website-graphql\",\n\t\tURL:        \"https://twin.sh/graphql\",\n\t\tMethod:     \"POST\",\n\t\tConditions: []Condition{condition},\n\t\tGraphQL:    true,\n\t\tBody: `{\n  users(gender: \"female\") {\n    id\n    name\n    gender\n    avatar\n  }\n}`,\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\trequest := endpoint.buildHTTPRequest()\n\tif request.Method != \"POST\" {\n\t\tt.Error(\"request.Method should've been POST, but was\", request.Method)\n\t}\n\tif contentType := request.Header.Get(ContentTypeHeader); contentType != \"application/json\" {\n\t\tt.Error(\"request.Header.Content-Type should've been application/json, but was\", contentType)\n\t}\n\tbody, _ := io.ReadAll(request.Body)\n\tif !strings.HasPrefix(string(body), \"{\\\"query\\\":\") {\n\t\tt.Error(\"request.body should've started with '{\\\"query\\\":', but it didn't:\", string(body))\n\t}\n}\n\nfunc TestIntegrationEvaluateHealth(t *testing.T) {\n\tcondition := Condition(\"[STATUS] == 200\")\n\tbodyCondition := Condition(\"[BODY].status == UP\")\n\tendpoint := Endpoint{\n\t\tName:       \"website-health\",\n\t\tURL:        \"https://twin.sh/health\",\n\t\tConditions: []Condition{condition, bodyCondition},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\tresult := endpoint.EvaluateHealth()\n\tif !result.ConditionResults[0].Success {\n\t\tt.Errorf(\"Condition '%s' should have been a success\", condition)\n\t}\n\tif !result.Connected {\n\t\tt.Error(\"Because the connection has been established, result.Connected should've been true\")\n\t}\n\tif !result.Success {\n\t\tt.Error(\"Because all conditions passed, this should have been a success\")\n\t}\n\tif result.Hostname != \"twin.sh\" {\n\t\tt.Error(\"result.Hostname should've been twin.sh, but was\", result.Hostname)\n\t}\n}\n\nfunc TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {\n\tendpoint := Endpoint{\n\t\tName:       \"invalid-url\",\n\t\tURL:        \"https://httpstat.us/200?sleep=100\",\n\t\tConditions: []Condition{Condition(\"[STATUS] == 200\")},\n\t\tClientConfig: &client.Config{\n\t\t\tTimeout: 1 * time.Millisecond,\n\t\t},\n\t\tUIConfig: &ui.Config{\n\t\t\tHideURL: true,\n\t\t},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\tresult := endpoint.EvaluateHealth()\n\tif result.Success {\n\t\tt.Error(\"Because one of the conditions was invalid, result.Success should have been false\")\n\t}\n\tif len(result.Errors) == 0 {\n\t\tt.Error(\"There should've been an error\")\n\t}\n\tif !strings.Contains(result.Errors[0], \"<redacted>\") || strings.Contains(result.Errors[0], endpoint.URL) {\n\t\tt.Error(\"result.Errors[0] should've had the URL redacted because ui.hide-url is set to true\")\n\t}\n}\n\nfunc TestIntegrationEvaluateHealthForDNS(t *testing.T) {\n\tconditionSuccess := Condition(\"[DNS_RCODE] == NOERROR\")\n\tconditionBody := Condition(\"[BODY] == pat(*.*.*.*)\")\n\tendpoint := Endpoint{\n\t\tName: \"example\",\n\t\tURL:  \"8.8.8.8\",\n\t\tDNSConfig: &dns.Config{\n\t\t\tQueryType: \"A\",\n\t\t\tQueryName: \"example.com.\",\n\t\t},\n\t\tConditions: []Condition{conditionSuccess, conditionBody},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\tresult := endpoint.EvaluateHealth()\n\tif !result.ConditionResults[0].Success {\n\t\tt.Errorf(\"Conditions '%s' and '%s' should have been a success\", conditionSuccess, conditionBody)\n\t}\n\tif !result.Connected {\n\t\tt.Error(\"Because the connection has been established, result.Connected should've been true\")\n\t}\n\tif !result.Success {\n\t\tt.Error(\"Because all conditions passed, this should have been a success\")\n\t}\n}\n\nfunc TestIntegrationEvaluateHealthForSSH(t *testing.T) {\n\tscenarios := []struct {\n\t\tname       string\n\t\tendpoint   Endpoint\n\t\tconditions []Condition\n\t\tsuccess    bool\n\t}{\n\t\t{\n\t\t\tname: \"ssh-success\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tName: \"ssh-success\",\n\t\t\t\tURL:  \"ssh://localhost\",\n\t\t\t\tSSHConfig: &ssh.Config{\n\t\t\t\t\tUsername: \"scenario\",\n\t\t\t\t\tPassword: \"scenario\",\n\t\t\t\t},\n\t\t\t\tBody: \"{ \\\"command\\\": \\\"uptime\\\" }\",\n\t\t\t},\n\t\t\tconditions: []Condition{Condition(\"[STATUS] == 0\")},\n\t\t\tsuccess:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"ssh-failure\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tName: \"ssh-failure\",\n\t\t\t\tURL:  \"ssh://localhost\",\n\t\t\t\tSSHConfig: &ssh.Config{\n\t\t\t\t\tUsername: \"scenario\",\n\t\t\t\t\tPassword: \"scenario\",\n\t\t\t\t},\n\t\t\t\tBody: \"{ \\\"command\\\": \\\"uptime\\\" }\",\n\t\t\t},\n\t\t\tconditions: []Condition{Condition(\"[STATUS] == 1\")},\n\t\t\tsuccess:    false,\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tscenario.endpoint.ValidateAndSetDefaults()\n\t\t\tscenario.endpoint.Conditions = scenario.conditions\n\t\t\tresult := scenario.endpoint.EvaluateHealth()\n\t\t\tif result.Success != scenario.success {\n\t\t\t\tt.Errorf(\"Expected success to be %v, but was %v\", scenario.success, result.Success)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIntegrationEvaluateHealthForICMP(t *testing.T) {\n\tendpoint := Endpoint{\n\t\tName:       \"icmp-test\",\n\t\tURL:        \"icmp://127.0.0.1\",\n\t\tConditions: []Condition{\"[CONNECTED] == true\"},\n\t}\n\terr := endpoint.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatal(\"did not expect an error, got\", err)\n\t}\n\tresult := endpoint.EvaluateHealth()\n\tif !result.ConditionResults[0].Success {\n\t\tt.Errorf(\"Conditions '%s' should have been a success\", endpoint.Conditions[0])\n\t}\n\tif !result.Connected {\n\t\tt.Error(\"Because the connection has been established, result.Connected should've been true\")\n\t}\n\tif !result.Success {\n\t\tt.Error(\"Because all conditions passed, this should have been a success\")\n\t}\n}\n\nfunc TestEndpoint_DisplayName(t *testing.T) {\n\tif endpoint := (Endpoint{Name: \"n\"}); endpoint.DisplayName() != \"n\" {\n\t\tt.Error(\"endpoint.DisplayName() should've been 'n', but was\", endpoint.DisplayName())\n\t}\n\tif endpoint := (Endpoint{Group: \"g\", Name: \"n\"}); endpoint.DisplayName() != \"g/n\" {\n\t\tt.Error(\"endpoint.DisplayName() should've been 'g/n', but was\", endpoint.DisplayName())\n\t}\n}\n\nfunc TestEndpoint_getIP(t *testing.T) {\n\tendpoint := Endpoint{\n\t\tName:       \"invalid-url-test\",\n\t\tURL:        \"\",\n\t\tConditions: []Condition{\"[CONNECTED] == true\"},\n\t}\n\tresult := &Result{}\n\tendpoint.getIP(result)\n\tif len(result.Errors) == 0 {\n\t\tt.Error(\"endpoint.getIP(result) should've thrown an error because the URL is invalid, thus cannot be parsed\")\n\t}\n}\n\nfunc TestEndpoint_needsToReadBody(t *testing.T) {\n\tstatusCondition := Condition(\"[STATUS] == 200\")\n\tbodyCondition := Condition(\"[BODY].status == UP\")\n\tbodyConditionWithLength := Condition(\"len([BODY].tags) > 0\")\n\tif (&Endpoint{Conditions: []Condition{statusCondition}}).needsToReadBody() {\n\t\tt.Error(\"expected false, got true\")\n\t}\n\tif !(&Endpoint{Conditions: []Condition{bodyCondition}}).needsToReadBody() {\n\t\tt.Error(\"expected true, got false\")\n\t}\n\tif !(&Endpoint{Conditions: []Condition{bodyConditionWithLength}}).needsToReadBody() {\n\t\tt.Error(\"expected true, got false\")\n\t}\n\tif !(&Endpoint{Conditions: []Condition{statusCondition, bodyCondition}}).needsToReadBody() {\n\t\tt.Error(\"expected true, got false\")\n\t}\n\tif !(&Endpoint{Conditions: []Condition{bodyCondition, statusCondition}}).needsToReadBody() {\n\t\tt.Error(\"expected true, got false\")\n\t}\n\tif !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {\n\t\tt.Error(\"expected true, got false\")\n\t}\n\t// Test store configuration with body placeholder\n\tstoreWithBodyPlaceholder := map[string]string{\n\t\t\"token\": \"[BODY].accessToken\",\n\t}\n\tif !(&Endpoint{\n\t\tConditions: []Condition{statusCondition},\n\t\tStore:      storeWithBodyPlaceholder,\n\t}).needsToReadBody() {\n\t\tt.Error(\"expected true when store has body placeholder, got false\")\n\t}\n\t// Test store configuration without body placeholder\n\tstoreWithoutBodyPlaceholder := map[string]string{\n\t\t\"status\": \"[STATUS]\",\n\t}\n\tif (&Endpoint{\n\t\tConditions: []Condition{statusCondition},\n\t\tStore:      storeWithoutBodyPlaceholder,\n\t}).needsToReadBody() {\n\t\tt.Error(\"expected false when store has no body placeholder, got true\")\n\t}\n\t// Test empty store\n\tif (&Endpoint{\n\t\tConditions: []Condition{statusCondition},\n\t\tStore:      map[string]string{},\n\t}).needsToReadBody() {\n\t\tt.Error(\"expected false when store is empty, got true\")\n\t}\n\t// Test nil store\n\tif (&Endpoint{\n\t\tConditions: []Condition{statusCondition},\n\t\tStore:      nil,\n\t}).needsToReadBody() {\n\t\tt.Error(\"expected false when store is nil, got true\")\n\t}\n}\n\nfunc TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {\n\tif (&Endpoint{Conditions: []Condition{\"[STATUS] == 200\"}}).needsToRetrieveDomainExpiration() {\n\t\tt.Error(\"expected false, got true\")\n\t}\n\tif !(&Endpoint{Conditions: []Condition{\"[STATUS] == 200\", \"[DOMAIN_EXPIRATION] < 720h\"}}).needsToRetrieveDomainExpiration() {\n\t\tt.Error(\"expected true, got false\")\n\t}\n}\n\nfunc TestEndpoint_needsToRetrieveIP(t *testing.T) {\n\tif (&Endpoint{Conditions: []Condition{\"[STATUS] == 200\"}}).needsToRetrieveIP() {\n\t\tt.Error(\"expected false, got true\")\n\t}\n\tif !(&Endpoint{Conditions: []Condition{\"[STATUS] == 200\", \"[IP] == 127.0.0.1\"}}).needsToRetrieveIP() {\n\t\tt.Error(\"expected true, got false\")\n\t}\n}\n\nfunc TestEndpoint_preprocessWithContext(t *testing.T) {\n\t// Import the gontext package for creating test contexts\n\t// This test thoroughly exercises the replaceContextPlaceholders function\n\ttests := []struct {\n\t\tname                  string\n\t\tendpoint              *Endpoint\n\t\tcontext               map[string]interface{}\n\t\texpectedURL           string\n\t\texpectedBody          string\n\t\texpectedHeaders       map[string]string\n\t\texpectedErrorCount    int\n\t\texpectedErrorContains []string\n\t}{\n\t\t{\n\t\t\tname: \"successful_url_replacement\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].userId\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"userId\": \"12345\",\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/users/12345\",\n\t\t\texpectedBody:       \"\",\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"successful_body_replacement\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com\",\n\t\t\t\tBody: `{\"userId\": \"[CONTEXT].userId\", \"action\": \"update\"}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"userId\": \"67890\",\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com\",\n\t\t\texpectedBody:       `{\"userId\": \"67890\", \"action\": \"update\"}`,\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"successful_header_replacement\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com\",\n\t\t\t\tBody: \"\",\n\t\t\t\tHeaders: map[string]string{\n\t\t\t\t\t\"Authorization\": \"Bearer [CONTEXT].token\",\n\t\t\t\t\t\"X-User-ID\":     \"[CONTEXT].userId\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"token\":  \"abc123token\",\n\t\t\t\t\"userId\": \"user123\",\n\t\t\t},\n\t\t\texpectedURL:  \"https://api.example.com\",\n\t\t\texpectedBody: \"\",\n\t\t\texpectedHeaders: map[string]string{\n\t\t\t\t\"Authorization\": \"Bearer abc123token\",\n\t\t\t\t\"X-User-ID\":     \"user123\",\n\t\t\t},\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple_placeholders_in_url\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://[CONTEXT].host/api/v[CONTEXT].version/users/[CONTEXT].userId\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"host\":    \"api.example.com\",\n\t\t\t\t\"version\": \"2\",\n\t\t\t\t\"userId\":  \"12345\",\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/api/v2/users/12345\",\n\t\t\texpectedBody:       \"\",\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"nested_context_path\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].user.id\",\n\t\t\t\tBody: `{\"name\": \"[CONTEXT].user.name\"}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"user\": map[string]interface{}{\n\t\t\t\t\t\"id\":   \"nested123\",\n\t\t\t\t\t\"name\": \"John Doe\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/users/nested123\",\n\t\t\texpectedBody:       `{\"name\": \"John Doe\"}`,\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"url_context_not_found\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].missingUserId\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"userId\": \"12345\", // different key\n\t\t\t},\n\t\t\texpectedURL:           \"https://api.example.com/users/[CONTEXT].missingUserId\",\n\t\t\texpectedBody:          \"\",\n\t\t\texpectedErrorCount:    1,\n\t\t\texpectedErrorContains: []string{\"path 'missingUserId' not found\"},\n\t\t},\n\t\t{\n\t\t\tname: \"body_context_not_found\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com\",\n\t\t\t\tBody: `{\"userId\": \"[CONTEXT].missingUserId\"}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"userId\": \"12345\", // different key\n\t\t\t},\n\t\t\texpectedURL:           \"https://api.example.com\",\n\t\t\texpectedBody:          `{\"userId\": \"[CONTEXT].missingUserId\"}`,\n\t\t\texpectedErrorCount:    1,\n\t\t\texpectedErrorContains: []string{\"path 'missingUserId' not found\"},\n\t\t},\n\t\t{\n\t\t\tname: \"header_context_not_found\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com\",\n\t\t\t\tBody: \"\",\n\t\t\t\tHeaders: map[string]string{\n\t\t\t\t\t\"Authorization\": \"Bearer [CONTEXT].missingToken\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"token\": \"validtoken\", // different key\n\t\t\t},\n\t\t\texpectedURL:  \"https://api.example.com\",\n\t\t\texpectedBody: \"\",\n\t\t\texpectedHeaders: map[string]string{\n\t\t\t\t\"Authorization\": \"Bearer [CONTEXT].missingToken\",\n\t\t\t},\n\t\t\texpectedErrorCount:    1,\n\t\t\texpectedErrorContains: []string{\"path 'missingToken' not found\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple_missing_context_paths\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId\",\n\t\t\t\tBody: `{\"token\": \"[CONTEXT].missingToken\"}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"validKey\": \"validValue\",\n\t\t\t},\n\t\t\texpectedURL:        \"https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId\",\n\t\t\texpectedBody:       `{\"token\": \"[CONTEXT].missingToken\"}`,\n\t\t\texpectedErrorCount: 2, // 1 for URL (both placeholders), 1 for Body\n\t\t\texpectedErrorContains: []string{\n\t\t\t\t\"path 'missingHost' not found\",\n\t\t\t\t\"path 'missingUserId' not found\",\n\t\t\t\t\"path 'missingToken' not found\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed_valid_and_invalid_placeholders\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].userId/posts/[CONTEXT].missingPostId\",\n\t\t\t\tBody: `{\"userId\": \"[CONTEXT].userId\", \"action\": \"[CONTEXT].missingAction\"}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"userId\": \"12345\",\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/users/12345/posts/[CONTEXT].missingPostId\",\n\t\t\texpectedBody:       `{\"userId\": \"12345\", \"action\": \"[CONTEXT].missingAction\"}`,\n\t\t\texpectedErrorCount: 2,\n\t\t\texpectedErrorContains: []string{\n\t\t\t\t\"path 'missingPostId' not found\",\n\t\t\t\t\"path 'missingAction' not found\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"nil_context\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].userId\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext:            nil,\n\t\t\texpectedURL:        \"https://api.example.com/users/[CONTEXT].userId\",\n\t\t\texpectedBody:       \"\",\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"empty_context\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].userId\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext:               map[string]interface{}{},\n\t\t\texpectedURL:           \"https://api.example.com/users/[CONTEXT].userId\",\n\t\t\texpectedBody:          \"\",\n\t\t\texpectedErrorCount:    1,\n\t\t\texpectedErrorContains: []string{\"path 'userId' not found\"},\n\t\t},\n\t\t{\n\t\t\tname: \"special_characters_in_context_values\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/search?q=[CONTEXT].query\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"query\": \"hello world & special chars!\",\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/search?q=hello world & special chars!\",\n\t\t\texpectedBody:       \"\",\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"numeric_context_values\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].userId/limit/[CONTEXT].limit\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"userId\": 12345,\n\t\t\t\t\"limit\":  100,\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/users/12345/limit/100\",\n\t\t\texpectedBody:       \"\",\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"boolean_context_values\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com\",\n\t\t\t\tBody: `{\"enabled\": [CONTEXT].enabled, \"active\": [CONTEXT].active}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"enabled\": true,\n\t\t\t\t\"active\":  false,\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com\",\n\t\t\texpectedBody:       `{\"enabled\": true, \"active\": false}`,\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"no_context_placeholders\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/health\",\n\t\t\t\tBody: `{\"status\": \"check\"}`,\n\t\t\t\tHeaders: map[string]string{\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"userId\": \"12345\",\n\t\t\t},\n\t\t\texpectedURL:  \"https://api.example.com/health\",\n\t\t\texpectedBody: `{\"status\": \"check\"}`,\n\t\t\texpectedHeaders: map[string]string{\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t},\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"deeply_nested_context_path\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].response.data.user.id\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"response\": map[string]interface{}{\n\t\t\t\t\t\"data\": map[string]interface{}{\n\t\t\t\t\t\t\"user\": map[string]interface{}{\n\t\t\t\t\t\t\t\"id\": \"deep123\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/users/deep123\",\n\t\t\texpectedBody:       \"\",\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid_nested_context_path\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].response.missing.path\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"response\": map[string]interface{}{\n\t\t\t\t\t\"data\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedURL:           \"https://api.example.com/users/[CONTEXT].response.missing.path\",\n\t\t\texpectedBody:          \"\",\n\t\t\texpectedErrorCount:    1,\n\t\t\texpectedErrorContains: []string{\"path 'response.missing.path' not found\"},\n\t\t},\n\t\t{\n\t\t\tname: \"hyphen_support_in_simple_keys\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].user-id\",\n\t\t\t\tBody: `{\"api-key\": \"[CONTEXT].api-key\", \"user-name\": \"[CONTEXT].user-name\"}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"user-id\":   \"user-12345\",\n\t\t\t\t\"api-key\":   \"key-abcdef\",\n\t\t\t\t\"user-name\": \"john-doe\",\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/users/user-12345\",\n\t\t\texpectedBody:       `{\"api-key\": \"key-abcdef\", \"user-name\": \"john-doe\"}`,\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"hyphen_support_in_headers\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com\",\n\t\t\t\tBody: \"\",\n\t\t\t\tHeaders: map[string]string{\n\t\t\t\t\t\"X-API-Key\":    \"[CONTEXT].api-key\",\n\t\t\t\t\t\"X-User-ID\":    \"[CONTEXT].user-id\",\n\t\t\t\t\t\"Content-Type\": \"[CONTEXT].content-type\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"api-key\":      \"secret-key-123\",\n\t\t\t\t\"user-id\":      \"user-456\",\n\t\t\t\t\"content-type\": \"application-json\",\n\t\t\t},\n\t\t\texpectedURL:  \"https://api.example.com\",\n\t\t\texpectedBody: \"\",\n\t\t\texpectedHeaders: map[string]string{\n\t\t\t\t\"X-API-Key\":    \"secret-key-123\",\n\t\t\t\t\"X-User-ID\":    \"user-456\",\n\t\t\t\t\"Content-Type\": \"application-json\",\n\t\t\t},\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"mixed_hyphens_underscores_and_dots\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/[CONTEXT].service-name/[CONTEXT].user_data.user-id\",\n\t\t\t\tBody: `{\"tenant-id\": \"[CONTEXT].tenant_config.tenant-id\"}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"service-name\": \"auth-service\",\n\t\t\t\t\"user_data\": map[string]interface{}{\n\t\t\t\t\t\"user-id\": \"user-789\",\n\t\t\t\t},\n\t\t\t\t\"tenant_config\": map[string]interface{}{\n\t\t\t\t\t\"tenant-id\": \"tenant-abc-123\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/auth-service/user-789\",\n\t\t\texpectedBody:       `{\"tenant-id\": \"tenant-abc-123\"}`,\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"hyphen_in_nested_paths\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].auth-response.user-data.profile-id\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"auth-response\": map[string]interface{}{\n\t\t\t\t\t\"user-data\": map[string]interface{}{\n\t\t\t\t\t\t\"profile-id\": \"profile-xyz-789\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/users/profile-xyz-789\",\n\t\t\texpectedBody:       \"\",\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"missing_hyphenated_context_key\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/users/[CONTEXT].missing-user-id\",\n\t\t\t\tBody: `{\"api-key\": \"[CONTEXT].missing-api-key\"}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"user-id\": \"valid-user\", // different key\n\t\t\t},\n\t\t\texpectedURL:           \"https://api.example.com/users/[CONTEXT].missing-user-id\",\n\t\t\texpectedBody:          `{\"api-key\": \"[CONTEXT].missing-api-key\"}`,\n\t\t\texpectedErrorCount:    2,\n\t\t\texpectedErrorContains: []string{\"path 'missing-user-id' not found\", \"path 'missing-api-key' not found\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple_hyphens_in_single_key\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/[CONTEXT].multi-hyphen-key-name\",\n\t\t\t\tBody: \"\",\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"multi-hyphen-key-name\": \"value-with-multiple-hyphens\",\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/value-with-multiple-hyphens\",\n\t\t\texpectedBody:       \"\",\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"hyphens_with_numeric_values\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com/limit/[CONTEXT].max-items\",\n\t\t\t\tBody: `{\"timeout-ms\": [CONTEXT].timeout-ms, \"retry-count\": [CONTEXT].retry-count}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"max-items\":   100,\n\t\t\t\t\"timeout-ms\":  5000,\n\t\t\t\t\"retry-count\": 3,\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com/limit/100\",\n\t\t\texpectedBody:       `{\"timeout-ms\": 5000, \"retry-count\": 3}`,\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"hyphens_with_boolean_values\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tURL:  \"https://api.example.com\",\n\t\t\t\tBody: `{\"enable-feature\": [CONTEXT].enable-feature, \"disable-cache\": [CONTEXT].disable-cache}`,\n\t\t\t},\n\t\t\tcontext: map[string]interface{}{\n\t\t\t\t\"enable-feature\": true,\n\t\t\t\t\"disable-cache\":  false,\n\t\t\t},\n\t\t\texpectedURL:        \"https://api.example.com\",\n\t\t\texpectedBody:       `{\"enable-feature\": true, \"disable-cache\": false}`,\n\t\t\texpectedErrorCount: 0,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Import gontext package for creating context\n\t\t\tvar ctx *gontext.Gontext\n\t\t\tif tt.context != nil {\n\t\t\t\tctx = gontext.New(tt.context)\n\t\t\t}\n\t\t\t// Create a new Result to capture errors\n\t\t\tresult := &Result{}\n\t\t\t// Call preprocessWithContext\n\t\t\tprocessed := tt.endpoint.preprocessWithContext(result, ctx)\n\t\t\t// Verify URL\n\t\t\tif processed.URL != tt.expectedURL {\n\t\t\t\tt.Errorf(\"URL mismatch:\\nexpected: %s\\nactual:   %s\", tt.expectedURL, processed.URL)\n\t\t\t}\n\t\t\t// Verify Body\n\t\t\tif processed.Body != tt.expectedBody {\n\t\t\t\tt.Errorf(\"Body mismatch:\\nexpected: %s\\nactual:   %s\", tt.expectedBody, processed.Body)\n\t\t\t}\n\t\t\t// Verify Headers\n\t\t\tif tt.expectedHeaders != nil {\n\t\t\t\tif processed.Headers == nil {\n\t\t\t\t\tt.Error(\"Expected headers but got nil\")\n\t\t\t\t} else {\n\t\t\t\t\tfor key, expectedValue := range tt.expectedHeaders {\n\t\t\t\t\t\tif actualValue, exists := processed.Headers[key]; !exists {\n\t\t\t\t\t\t\tt.Errorf(\"Expected header %s not found\", key)\n\t\t\t\t\t\t} else if actualValue != expectedValue {\n\t\t\t\t\t\t\tt.Errorf(\"Header %s mismatch:\\nexpected: %s\\nactual:   %s\", key, expectedValue, actualValue)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Verify error count\n\t\t\tif len(result.Errors) != tt.expectedErrorCount {\n\t\t\t\tt.Errorf(\"Error count mismatch:\\nexpected: %d\\nactual:   %d\\nerrors: %v\", tt.expectedErrorCount, len(result.Errors), result.Errors)\n\t\t\t}\n\t\t\t// Verify error messages contain expected strings\n\t\t\tif tt.expectedErrorContains != nil {\n\t\t\t\tactualErrors := strings.Join(result.Errors, \" \")\n\t\t\t\tfor _, expectedError := range tt.expectedErrorContains {\n\t\t\t\t\tif !strings.Contains(actualErrors, expectedError) {\n\t\t\t\t\t\tt.Errorf(\"Expected error containing '%s' not found in: %v\", expectedError, result.Errors)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Verify original endpoint is not modified\n\t\t\tif tt.endpoint.URL != ((&Endpoint{URL: tt.endpoint.URL, Body: tt.endpoint.Body, Headers: tt.endpoint.Headers}).URL) {\n\t\t\t\tt.Error(\"Original endpoint was modified\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEndpoint_HideUIFeatures(t *testing.T) {\n\tdefer client.InjectHTTPClient(nil)\n\ttests := []struct {\n\t\tname              string\n\t\tendpoint          Endpoint\n\t\tmockResponse      test.MockRoundTripper\n\t\tcheckHostname     bool\n\t\texpectHostname    string\n\t\tcheckErrors       bool\n\t\texpectErrors      bool\n\t\tcheckConditions   bool\n\t\texpectConditions  bool\n\t\tcheckErrorContent string\n\t}{\n\t\t{\n\t\t\tname: \"hide-conditions\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tName:       \"test-endpoint\",\n\t\t\t\tURL:        \"https://example.com/health\",\n\t\t\t\tConditions: []Condition{\"[STATUS] == 200\", \"[BODY].status == UP\"},\n\t\t\t\tUIConfig:   &ui.Config{HideConditions: true},\n\t\t\t},\n\t\t\tmockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{\"status\": \"UP\"}`))}\n\t\t\t}),\n\t\t\tcheckConditions:  true,\n\t\t\texpectConditions: false,\n\t\t},\n\t\t{\n\t\t\tname: \"hide-hostname\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tName:       \"test-endpoint\",\n\t\t\t\tURL:        \"https://example.com/health\",\n\t\t\t\tConditions: []Condition{\"[STATUS] == 200\"},\n\t\t\t\tUIConfig:   &ui.Config{HideHostname: true},\n\t\t\t},\n\t\t\tmockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tcheckHostname:  true,\n\t\t\texpectHostname: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"hide-url-in-errors\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tName:         \"test-endpoint\",\n\t\t\t\tURL:          \"https://example.com/health\",\n\t\t\t\tConditions:   []Condition{\"[CONNECTED] == true\"},\n\t\t\t\tUIConfig:     &ui.Config{HideURL: true},\n\t\t\t\tClientConfig: &client.Config{Timeout: time.Millisecond},\n\t\t\t},\n\t\t\tmockResponse:      nil,\n\t\t\tcheckErrors:       true,\n\t\t\texpectErrors:      true,\n\t\t\tcheckErrorContent: \"<redacted>\",\n\t\t},\n\t\t{\n\t\t\tname: \"hide-port-in-errors\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tName:         \"test-endpoint\",\n\t\t\t\tURL:          \"https://example.com:9999/health\",\n\t\t\t\tConditions:   []Condition{\"[CONNECTED] == true\"},\n\t\t\t\tUIConfig:     &ui.Config{HidePort: true},\n\t\t\t\tClientConfig: &client.Config{Timeout: time.Millisecond},\n\t\t\t},\n\t\t\tmockResponse:      nil,\n\t\t\tcheckErrors:       true,\n\t\t\texpectErrors:      true,\n\t\t\tcheckErrorContent: \"<redacted>\",\n\t\t},\n\t\t{\n\t\t\tname: \"hide-errors\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tName:         \"test-endpoint\",\n\t\t\t\tURL:          \"https://example.com/health\",\n\t\t\t\tConditions:   []Condition{\"[CONNECTED] == true\"},\n\t\t\t\tUIConfig:     &ui.Config{HideErrors: true},\n\t\t\t\tClientConfig: &client.Config{Timeout: time.Millisecond},\n\t\t\t},\n\t\t\tmockResponse: nil,\n\t\t\tcheckErrors:  true,\n\t\t\texpectErrors: false,\n\t\t},\n\t\t{\n\t\t\tname: \"dont-resolve-failed-conditions\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tName:       \"test-endpoint\",\n\t\t\t\tURL:        \"https://example.com/health\",\n\t\t\t\tConditions: []Condition{\"[STATUS] == 200\"},\n\t\t\t\tUIConfig:   &ui.Config{DontResolveFailedConditions: true},\n\t\t\t},\n\t\t\tmockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}\n\t\t\t}),\n\t\t\tcheckConditions:  true,\n\t\t\texpectConditions: true,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple-hide-features\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tName:       \"test-endpoint\",\n\t\t\t\tURL:        \"https://example.com/health\",\n\t\t\t\tConditions: []Condition{\"[STATUS] == 200\"},\n\t\t\t\tUIConfig:   &ui.Config{HideConditions: true, HideHostname: true, HideErrors: true},\n\t\t\t},\n\t\t\tmockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}\n\t\t\t}),\n\t\t\tcheckConditions:  true,\n\t\t\texpectConditions: false,\n\t\t\tcheckHostname:    true,\n\t\t\texpectHostname:   \"\",\n\t\t\tcheckErrors:      true,\n\t\t\texpectErrors:     false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif tt.mockResponse != nil {\n\t\t\t\tmockClient := &http.Client{Transport: tt.mockResponse}\n\t\t\t\tif tt.endpoint.ClientConfig != nil && tt.endpoint.ClientConfig.Timeout > 0 {\n\t\t\t\t\tmockClient.Timeout = tt.endpoint.ClientConfig.Timeout\n\t\t\t\t}\n\t\t\t\tclient.InjectHTTPClient(mockClient)\n\t\t\t} else {\n\t\t\t\tclient.InjectHTTPClient(nil)\n\t\t\t}\n\t\t\terr := tt.endpoint.ValidateAndSetDefaults()\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"ValidateAndSetDefaults failed: %v\", err)\n\t\t\t}\n\t\t\tresult := tt.endpoint.EvaluateHealth()\n\t\t\tif tt.checkHostname {\n\t\t\t\tif result.Hostname != tt.expectHostname {\n\t\t\t\t\tt.Errorf(\"Expected hostname '%s', got '%s'\", tt.expectHostname, result.Hostname)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif tt.checkErrors {\n\t\t\t\thasErrors := len(result.Errors) > 0\n\t\t\t\tif hasErrors != tt.expectErrors {\n\t\t\t\t\tt.Errorf(\"Expected errors=%v, got errors=%v (actual errors: %v)\", tt.expectErrors, hasErrors, result.Errors)\n\t\t\t\t}\n\t\t\t\tif tt.checkErrorContent != \"\" && len(result.Errors) > 0 {\n\t\t\t\t\tfound := false\n\t\t\t\t\tfor _, err := range result.Errors {\n\t\t\t\t\t\tif strings.Contains(err, tt.checkErrorContent) {\n\t\t\t\t\t\t\tfound = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif !found {\n\t\t\t\t\t\tt.Errorf(\"Expected error to contain '%s', but got: %v\", tt.checkErrorContent, result.Errors)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif tt.checkConditions {\n\t\t\t\thasConditions := len(result.ConditionResults) > 0\n\t\t\t\tif hasConditions != tt.expectConditions {\n\t\t\t\t\tt.Errorf(\"Expected conditions=%v, got conditions=%v (actual: %v)\", tt.expectConditions, hasConditions, result.ConditionResults)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/event.go",
    "content": "package endpoint\n\nimport (\n\t\"time\"\n)\n\n// Event is something that happens at a specific time\ntype Event struct {\n\t// Type is the kind of event\n\tType EventType `json:\"type\"`\n\n\t// Timestamp is the moment at which the event happened\n\tTimestamp time.Time `json:\"timestamp\"`\n}\n\n// EventType is, uh, the types of events?\ntype EventType string\n\nvar (\n\t// EventStart is a type of event that represents when an endpoint starts being monitored\n\tEventStart EventType = \"START\"\n\n\t// EventHealthy is a type of event that represents an endpoint passing all of its conditions\n\tEventHealthy EventType = \"HEALTHY\"\n\n\t// EventUnhealthy is a type of event that represents an endpoint failing one or more of its conditions\n\tEventUnhealthy EventType = \"UNHEALTHY\"\n)\n\n// NewEventFromResult creates an Event from a Result\nfunc NewEventFromResult(result *Result) *Event {\n\tevent := &Event{Timestamp: result.Timestamp}\n\tif result.Success {\n\t\tevent.Type = EventHealthy\n\t} else {\n\t\tevent.Type = EventUnhealthy\n\t}\n\treturn event\n}\n"
  },
  {
    "path": "config/endpoint/event_test.go",
    "content": "package endpoint\n\nimport (\n\t\"testing\"\n)\n\nfunc TestNewEventFromResult(t *testing.T) {\n\tif event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy {\n\t\tt.Error(\"expected event.Type to be EventHealthy\")\n\t}\n\tif event := NewEventFromResult(&Result{Success: false}); event.Type != EventUnhealthy {\n\t\tt.Error(\"expected event.Type to be EventUnhealthy\")\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/external_endpoint.go",
    "content": "package endpoint\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/heartbeat\"\n\t\"github.com/TwiN/gatus/v5/config/key\"\n\t\"github.com/TwiN/gatus/v5/config/maintenance\"\n)\n\nvar (\n\t// ErrExternalEndpointWithNoToken is the error with which Gatus will panic if an external endpoint is configured without a token.\n\tErrExternalEndpointWithNoToken = errors.New(\"you must specify a token for each external endpoint\")\n\n\t// ErrExternalEndpointHeartbeatIntervalTooLow is the error with which Gatus will panic if an external endpoint's heartbeat interval is less than 10 seconds.\n\tErrExternalEndpointHeartbeatIntervalTooLow = errors.New(\"heartbeat interval must be at least 10 seconds\")\n)\n\n// ExternalEndpoint is an endpoint whose result is pushed from outside Gatus, which means that\n// said endpoints are not monitored by Gatus itself; Gatus only displays their results and takes\n// care of alerting\ntype ExternalEndpoint struct {\n\t// Enabled defines whether to enable the monitoring of the endpoint\n\tEnabled *bool `yaml:\"enabled,omitempty\"`\n\n\t// Name of the endpoint. Can be anything.\n\tName string `yaml:\"name\"`\n\n\t// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.\n\tGroup string `yaml:\"group,omitempty\"`\n\n\t// Token is the bearer token that must be provided through the Authorization header to push results to the endpoint\n\tToken string `yaml:\"token,omitempty\"`\n\n\t// Alerts is the alerting configuration for the endpoint in case of failure\n\tAlerts []*alert.Alert `yaml:\"alerts,omitempty\"`\n\n\t// MaintenanceWindow is the configuration for per-endpoint maintenance windows\n\tMaintenanceWindows []*maintenance.Config `yaml:\"maintenance-windows,omitempty\"`\n\n\t// Heartbeat is the configuration that checks if the external endpoint has received new results when it should have.\n\tHeartbeat heartbeat.Config `yaml:\"heartbeat,omitempty\"`\n\n\t// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row\n\tNumberOfFailuresInARow int `yaml:\"-\"`\n\n\t// NumberOfSuccessesInARow is the number of successful evaluations in a row\n\tNumberOfSuccessesInARow int `yaml:\"-\"`\n}\n\n// ValidateAndSetDefaults validates the ExternalEndpoint and sets the default values\nfunc (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error {\n\tif err := validateEndpointNameGroupAndAlerts(externalEndpoint.Name, externalEndpoint.Group, externalEndpoint.Alerts); err != nil {\n\t\treturn err\n\t}\n\tif len(externalEndpoint.Token) == 0 {\n\t\treturn ErrExternalEndpointWithNoToken\n\t}\n\tif externalEndpoint.Heartbeat.Interval != 0 && externalEndpoint.Heartbeat.Interval < 10*time.Second {\n\t\t// If the heartbeat interval is set (non-0), it must be at least 10 seconds.\n\t\treturn ErrExternalEndpointHeartbeatIntervalTooLow\n\t}\n\treturn nil\n}\n\n// IsEnabled returns whether the endpoint is enabled or not\nfunc (externalEndpoint *ExternalEndpoint) IsEnabled() bool {\n\tif externalEndpoint.Enabled == nil {\n\t\treturn true\n\t}\n\treturn *externalEndpoint.Enabled\n}\n\n// DisplayName returns an identifier made up of the Name and, if not empty, the Group.\nfunc (externalEndpoint *ExternalEndpoint) DisplayName() string {\n\tif len(externalEndpoint.Group) > 0 {\n\t\treturn externalEndpoint.Group + \"/\" + externalEndpoint.Name\n\t}\n\treturn externalEndpoint.Name\n}\n\n// Key returns the unique key for the Endpoint\nfunc (externalEndpoint *ExternalEndpoint) Key() string {\n\treturn key.ConvertGroupAndNameToKey(externalEndpoint.Group, externalEndpoint.Name)\n}\n\n// ToEndpoint converts the ExternalEndpoint to an Endpoint\nfunc (externalEndpoint *ExternalEndpoint) ToEndpoint() *Endpoint {\n\tendpoint := &Endpoint{\n\t\tEnabled:                 externalEndpoint.Enabled,\n\t\tName:                    externalEndpoint.Name,\n\t\tGroup:                   externalEndpoint.Group,\n\t\tAlerts:                  externalEndpoint.Alerts,\n\t\tNumberOfFailuresInARow:  externalEndpoint.NumberOfFailuresInARow,\n\t\tNumberOfSuccessesInARow: externalEndpoint.NumberOfSuccessesInARow,\n\t}\n\treturn endpoint\n}\n"
  },
  {
    "path": "config/endpoint/external_endpoint_test.go",
    "content": "package endpoint\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/heartbeat\"\n\t\"github.com/TwiN/gatus/v5/config/maintenance\"\n)\n\nfunc TestExternalEndpoint_ValidateAndSetDefaults(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tendpoint *ExternalEndpoint\n\t\twantErr  error\n\t}{\n\t\t{\n\t\t\tname: \"valid-external-endpoint\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tGroup: \"test-group\",\n\t\t\t\tToken: \"valid-token\",\n\t\t\t},\n\t\t\twantErr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"valid-external-endpoint-with-heartbeat\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tToken: \"valid-token\",\n\t\t\t\tHeartbeat: heartbeat.Config{\n\t\t\t\t\tInterval: 30 * time.Second,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"missing-token\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tGroup: \"test-group\",\n\t\t\t},\n\t\t\twantErr: ErrExternalEndpointWithNoToken,\n\t\t},\n\t\t{\n\t\t\tname: \"empty-token\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tToken: \"\",\n\t\t\t},\n\t\t\twantErr: ErrExternalEndpointWithNoToken,\n\t\t},\n\t\t{\n\t\t\tname: \"heartbeat-interval-too-low\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tToken: \"valid-token\",\n\t\t\t\tHeartbeat: heartbeat.Config{\n\t\t\t\t\tInterval: 5 * time.Second, // Less than 10 seconds\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: ErrExternalEndpointHeartbeatIntervalTooLow,\n\t\t},\n\t\t{\n\t\t\tname: \"heartbeat-interval-exactly-10-seconds\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tToken: \"valid-token\",\n\t\t\t\tHeartbeat: heartbeat.Config{\n\t\t\t\t\tInterval: 10 * time.Second,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"heartbeat-interval-zero-is-allowed\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tToken: \"valid-token\",\n\t\t\t\tHeartbeat: heartbeat.Config{\n\t\t\t\t\tInterval: 0, // Zero means no heartbeat monitoring\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"missing-name\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tGroup: \"test-group\",\n\t\t\t\tToken: \"valid-token\",\n\t\t\t},\n\t\t\twantErr: ErrEndpointWithNoName,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.endpoint.ValidateAndSetDefaults()\n\t\t\tif tt.wantErr != nil {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error %v, but got none\", tt.wantErr)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif err.Error() != tt.wantErr.Error() {\n\t\t\t\t\tt.Errorf(\"Expected error %v, got %v\", tt.wantErr, err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Expected no error, but got %v\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExternalEndpoint_IsEnabled(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tenabled  *bool\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"nil-enabled-defaults-to-true\",\n\t\t\tenabled:  nil,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"explicitly-enabled\",\n\t\t\tenabled:  boolPtr(true),\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"explicitly-disabled\",\n\t\t\tenabled:  boolPtr(false),\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tendpoint := &ExternalEndpoint{\n\t\t\t\tName:    \"test-endpoint\",\n\t\t\t\tToken:   \"test-token\",\n\t\t\t\tEnabled: tt.enabled,\n\t\t\t}\n\t\t\tresult := endpoint.IsEnabled()\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"Expected %v, got %v\", tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExternalEndpoint_DisplayName(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tendpoint *ExternalEndpoint\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"with-group\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tGroup: \"test-group\",\n\t\t\t},\n\t\t\texpected: \"test-group/test-endpoint\",\n\t\t},\n\t\t{\n\t\t\tname: \"without-group\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tGroup: \"\",\n\t\t\t},\n\t\t\texpected: \"test-endpoint\",\n\t\t},\n\t\t{\n\t\t\tname: \"empty-group-string\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"api-health\",\n\t\t\t\tGroup: \"\",\n\t\t\t},\n\t\t\texpected: \"api-health\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := tt.endpoint.DisplayName()\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"Expected %q, got %q\", tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExternalEndpoint_Key(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tendpoint *ExternalEndpoint\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"with-group\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tGroup: \"test-group\",\n\t\t\t},\n\t\t\texpected: \"test-group_test-endpoint\",\n\t\t},\n\t\t{\n\t\t\tname: \"without-group\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tGroup: \"\",\n\t\t\t},\n\t\t\texpected: \"_test-endpoint\",\n\t\t},\n\t\t{\n\t\t\tname: \"special-characters-in-name\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test endpoint with spaces\",\n\t\t\t\tGroup: \"test-group\",\n\t\t\t},\n\t\t\texpected: \"test-group_test-endpoint-with-spaces\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := tt.endpoint.Key()\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"Expected %q, got %q\", tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExternalEndpoint_ToEndpoint(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\texternalEndpoint *ExternalEndpoint\n\t}{\n\t\t{\n\t\t\tname: \"complete-external-endpoint\",\n\t\t\texternalEndpoint: &ExternalEndpoint{\n\t\t\t\tEnabled: boolPtr(true),\n\t\t\t\tName:    \"test-endpoint\",\n\t\t\t\tGroup:   \"test-group\",\n\t\t\t\tToken:   \"test-token\",\n\t\t\t\tAlerts: []*alert.Alert{\n\t\t\t\t\t{\n\t\t\t\t\t\tType: alert.TypeSlack,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tMaintenanceWindows: []*maintenance.Config{\n\t\t\t\t\t{\n\t\t\t\t\t\tStart:    \"02:00\",\n\t\t\t\t\t\tDuration: time.Hour,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tNumberOfFailuresInARow:  3,\n\t\t\t\tNumberOfSuccessesInARow: 5,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"minimal-external-endpoint\",\n\t\t\texternalEndpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"minimal-endpoint\",\n\t\t\t\tToken: \"minimal-token\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"disabled-external-endpoint\",\n\t\t\texternalEndpoint: &ExternalEndpoint{\n\t\t\t\tEnabled: boolPtr(false),\n\t\t\t\tName:    \"disabled-endpoint\",\n\t\t\t\tToken:   \"disabled-token\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"original-test-case\",\n\t\t\texternalEndpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"name\",\n\t\t\t\tGroup: \"group\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := tt.externalEndpoint.ToEndpoint()\n\t\t\t// Verify all fields are correctly copied\n\t\t\tif result.Enabled != tt.externalEndpoint.Enabled {\n\t\t\t\tt.Errorf(\"Expected Enabled=%v, got %v\", tt.externalEndpoint.Enabled, result.Enabled)\n\t\t\t}\n\t\t\tif result.Name != tt.externalEndpoint.Name {\n\t\t\t\tt.Errorf(\"Expected Name=%q, got %q\", tt.externalEndpoint.Name, result.Name)\n\t\t\t}\n\t\t\tif result.Group != tt.externalEndpoint.Group {\n\t\t\t\tt.Errorf(\"Expected Group=%q, got %q\", tt.externalEndpoint.Group, result.Group)\n\t\t\t}\n\t\t\tif len(result.Alerts) != len(tt.externalEndpoint.Alerts) {\n\t\t\t\tt.Errorf(\"Expected %d alerts, got %d\", len(tt.externalEndpoint.Alerts), len(result.Alerts))\n\t\t\t}\n\t\t\tif result.NumberOfFailuresInARow != tt.externalEndpoint.NumberOfFailuresInARow {\n\t\t\t\tt.Errorf(\"Expected NumberOfFailuresInARow=%d, got %d\", tt.externalEndpoint.NumberOfFailuresInARow, result.NumberOfFailuresInARow)\n\t\t\t}\n\t\t\tif result.NumberOfSuccessesInARow != tt.externalEndpoint.NumberOfSuccessesInARow {\n\t\t\t\tt.Errorf(\"Expected NumberOfSuccessesInARow=%d, got %d\", tt.externalEndpoint.NumberOfSuccessesInARow, result.NumberOfSuccessesInARow)\n\t\t\t}\n\t\t\t// Original test assertions\n\t\t\tif tt.externalEndpoint.Key() != result.Key() {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", tt.externalEndpoint.Key(), result.Key())\n\t\t\t}\n\t\t\tif tt.externalEndpoint.DisplayName() != result.DisplayName() {\n\t\t\t\tt.Errorf(\"expected %s, got %s\", tt.externalEndpoint.DisplayName(), result.DisplayName())\n\t\t\t}\n\t\t\t// Verify it's a proper Endpoint type\n\t\t\tif result == nil {\n\t\t\t\tt.Error(\"ToEndpoint() returned nil\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExternalEndpoint_ValidationEdgeCases(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tendpoint *ExternalEndpoint\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname: \"very-long-name\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"this-is-a-very-long-endpoint-name-that-might-cause-issues-in-some-systems-but-should-be-handled-gracefully\",\n\t\t\t\tToken: \"valid-token\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"special-characters-in-name\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint@#$%^&*()\",\n\t\t\t\tToken: \"valid-token\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"unicode-characters-in-name\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"测试端点\",\n\t\t\t\tToken: \"valid-token\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"very-long-token\",\n\t\t\tendpoint: &ExternalEndpoint{\n\t\t\t\tName:  \"test-endpoint\",\n\t\t\t\tToken: \"very-long-token-that-should-still-be-valid-even-though-it-is-extremely-long-and-might-not-be-practical-in-real-world-scenarios\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.endpoint.ValidateAndSetDefaults()\n\t\t\tif tt.wantErr && err == nil {\n\t\t\t\tt.Error(\"Expected error but got none\")\n\t\t\t}\n\t\t\tif !tt.wantErr && err != nil {\n\t\t\t\tt.Errorf(\"Expected no error but got: %v\", err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Helper function to create bool pointers\nfunc boolPtr(b bool) *bool {\n\treturn &b\n}\n"
  },
  {
    "path": "config/endpoint/heartbeat/heartbeat.go",
    "content": "package heartbeat\n\nimport \"time\"\n\n// Config used to check if the external endpoint has received new results when it should have.\n// This configuration is used to trigger alerts when an external endpoint has no new results for a defined period of time\ntype Config struct {\n\t// Interval is the time interval at which Gatus verifies whether the external endpoint has received new results\n\t// If no new result is received within the interval, the endpoint is marked as failed and alerts are triggered\n\tInterval time.Duration `yaml:\"interval\"`\n}\n"
  },
  {
    "path": "config/endpoint/placeholder.go",
    "content": "package endpoint\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/TwiN/gatus/v5/config/gontext\"\n\t\"github.com/TwiN/gatus/v5/jsonpath\"\n)\n\n// Placeholders\nconst (\n\t// StatusPlaceholder is a placeholder for a HTTP status.\n\t//\n\t// Values that could replace the placeholder: 200, 404, 500, ...\n\tStatusPlaceholder = \"[STATUS]\"\n\n\t// IPPlaceholder is a placeholder for an IP.\n\t//\n\t// Values that could replace the placeholder: 127.0.0.1, 10.0.0.1, ...\n\tIPPlaceholder = \"[IP]\"\n\n\t// DNSRCodePlaceholder is a placeholder for DNS_RCODE\n\t//\n\t// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED\n\tDNSRCodePlaceholder = \"[DNS_RCODE]\"\n\n\t// ResponseTimePlaceholder is a placeholder for the request response time, in milliseconds.\n\t//\n\t// Values that could replace the placeholder: 1, 500, 1000, ...\n\tResponseTimePlaceholder = \"[RESPONSE_TIME]\"\n\n\t// BodyPlaceholder is a placeholder for the Body of the response\n\t//\n\t// Values that could replace the placeholder: {}, {\"data\":{\"name\":\"john\"}}, ...\n\tBodyPlaceholder = \"[BODY]\"\n\n\t// ConnectedPlaceholder is a placeholder for whether a connection was successfully established.\n\t//\n\t// Values that could replace the placeholder: true, false\n\tConnectedPlaceholder = \"[CONNECTED]\"\n\n\t// CertificateExpirationPlaceholder is a placeholder for the duration before certificate expiration, in milliseconds.\n\t//\n\t// Values that could replace the placeholder: 4461677039 (~52 days)\n\tCertificateExpirationPlaceholder = \"[CERTIFICATE_EXPIRATION]\"\n\n\t// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.\n\tDomainExpirationPlaceholder = \"[DOMAIN_EXPIRATION]\"\n\n\t// ContextPlaceholder is a placeholder for suite context values\n\t// Usage: [CONTEXT].path.to.value\n\tContextPlaceholder = \"[CONTEXT]\"\n)\n\n// Functions\nconst (\n\t// LengthFunctionPrefix is the prefix for the length function\n\t//\n\t// Usage: len([BODY].articles) == 10, len([BODY].name) > 5\n\tLengthFunctionPrefix = \"len(\"\n\n\t// HasFunctionPrefix is the prefix for the has function\n\t//\n\t// Usage: has([BODY].errors) == true\n\tHasFunctionPrefix = \"has(\"\n\n\t// PatternFunctionPrefix is the prefix for the pattern function\n\t//\n\t// Usage: [IP] == pat(192.168.*.*)\n\tPatternFunctionPrefix = \"pat(\"\n\n\t// AnyFunctionPrefix is the prefix for the any function\n\t//\n\t// Usage: [IP] == any(1.1.1.1, 1.0.0.1)\n\tAnyFunctionPrefix = \"any(\"\n\n\t// FunctionSuffix is the suffix for all functions\n\tFunctionSuffix = \")\"\n)\n\n// Other constants\nconst (\n\t// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition\n\tInvalidConditionElementSuffix = \"(INVALID)\"\n)\n\n// functionType represents the type of function wrapper\ntype functionType int\n\nconst (\n\t// Note that not all functions are handled here. Only len() and has() directly impact the handler\n\t// e.g. \"len([BODY].name) > 0\" vs pat() or any(), which would be used like \"[BODY].name == pat(john*)\"\n\n\tnoFunction functionType = iota\n\tfunctionLen\n\tfunctionHas\n)\n\n// ResolvePlaceholder resolves all types of placeholders to their string values.\n//\n// Supported placeholders:\n//   - [STATUS]: HTTP status code (e.g., \"200\", \"404\")\n//   - [IP]: IP address from the response (e.g., \"127.0.0.1\")\n//   - [RESPONSE_TIME]: Response time in milliseconds (e.g., \"250\")\n//   - [DNS_RCODE]: DNS response code (e.g., \"NOERROR\", \"NXDOMAIN\")\n//   - [CONNECTED]: Connection status (e.g., \"true\", \"false\")\n//   - [CERTIFICATE_EXPIRATION]: Certificate expiration time in milliseconds\n//   - [DOMAIN_EXPIRATION]: Domain expiration time in milliseconds\n//   - [BODY]: Full response body\n//   - [BODY].path: JSONPath expression on response body (e.g., [BODY].status, [BODY].data[0].name)\n//   - [CONTEXT].path: Suite context values (e.g., [CONTEXT].user_id, [CONTEXT].session_token)\n//\n// Function wrappers:\n//   - len(placeholder): Returns the length of the resolved value\n//   - has(placeholder): Returns \"true\" if the placeholder exists and is non-empty, \"false\" otherwise\n//\n// Examples:\n//   - ResolvePlaceholder(\"[STATUS]\", result, nil) → \"200\"\n//   - ResolvePlaceholder(\"len([BODY].items)\", result, nil) → \"5\" (for JSON array with 5 items)\n//   - ResolvePlaceholder(\"has([CONTEXT].user_id)\", result, ctx) → \"true\" (if context has user_id)\n//   - ResolvePlaceholder(\"[BODY].user.name\", result, nil) → \"john\" (for {\"user\":{\"name\":\"john\"}})\n//\n// Case-insensitive: All placeholder names are handled case-insensitively, but paths preserve original case.\nfunc ResolvePlaceholder(placeholder string, result *Result, ctx *gontext.Gontext) (string, error) {\n\tplaceholder = strings.TrimSpace(placeholder)\n\toriginalPlaceholder := placeholder\n\n\t// Extract function wrapper if present\n\tfn, innerPlaceholder := extractFunctionWrapper(placeholder)\n\tplaceholder = innerPlaceholder\n\n\t// Handle CONTEXT placeholders\n\tuppercasePlaceholder := strings.ToUpper(placeholder)\n\tif strings.HasPrefix(uppercasePlaceholder, ContextPlaceholder) && ctx != nil {\n\t\treturn resolveContextPlaceholder(placeholder, fn, originalPlaceholder, ctx)\n\t}\n\n\t// Handle basic placeholders (try uppercase first for backward compatibility)\n\tswitch uppercasePlaceholder {\n\tcase StatusPlaceholder:\n\t\treturn formatWithFunction(strconv.Itoa(result.HTTPStatus), fn), nil\n\tcase IPPlaceholder:\n\t\treturn formatWithFunction(result.IP, fn), nil\n\tcase ResponseTimePlaceholder:\n\t\treturn formatWithFunction(strconv.FormatInt(result.Duration.Milliseconds(), 10), fn), nil\n\tcase DNSRCodePlaceholder:\n\t\treturn formatWithFunction(result.DNSRCode, fn), nil\n\tcase ConnectedPlaceholder:\n\t\treturn formatWithFunction(strconv.FormatBool(result.Connected), fn), nil\n\tcase CertificateExpirationPlaceholder:\n\t\treturn formatWithFunction(strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10), fn), nil\n\tcase DomainExpirationPlaceholder:\n\t\treturn formatWithFunction(strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10), fn), nil\n\tcase BodyPlaceholder:\n\t\tbody := strings.TrimSpace(string(result.Body))\n\t\tif fn == functionHas {\n\t\t\treturn strconv.FormatBool(len(body) > 0), nil\n\t\t}\n\t\tif fn == functionLen {\n\t\t\t// For len([BODY]), we need to check if it's JSON and get the actual length\n\t\t\t// Use jsonpath to evaluate the root element\n\t\t\t_, resolvedLength, err := jsonpath.Eval(\"\", result.Body)\n\t\t\tif err == nil {\n\t\t\t\treturn strconv.Itoa(resolvedLength), nil\n\t\t\t}\n\t\t\t// Fall back to string length if not valid JSON\n\t\t\treturn strconv.Itoa(len(body)), nil\n\t\t}\n\t\treturn body, nil\n\t}\n\n\t// Handle JSONPath expressions on BODY (including array indexing)\n\tif strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder+\".\") || strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder+\"[\") {\n\t\treturn resolveJSONPathPlaceholder(placeholder, fn, originalPlaceholder, result)\n\t}\n\n\t// Not a recognized placeholder\n\tif fn != noFunction {\n\t\tif fn == functionHas {\n\t\t\treturn \"false\", nil\n\t\t}\n\t\t// For len() with unrecognized placeholder, return with INVALID suffix\n\t\treturn originalPlaceholder + \" \" + InvalidConditionElementSuffix, nil\n\t}\n\n\t// Return the original placeholder if we can't resolve it\n\t// This allows for literal string comparisons\n\treturn originalPlaceholder, nil\n}\n\n// extractFunctionWrapper detects and extracts function wrappers (len, has)\nfunc extractFunctionWrapper(placeholder string) (functionType, string) {\n\tif strings.HasPrefix(placeholder, LengthFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) {\n\t\tinner := strings.TrimSuffix(strings.TrimPrefix(placeholder, LengthFunctionPrefix), FunctionSuffix)\n\t\treturn functionLen, inner\n\t}\n\tif strings.HasPrefix(placeholder, HasFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) {\n\t\tinner := strings.TrimSuffix(strings.TrimPrefix(placeholder, HasFunctionPrefix), FunctionSuffix)\n\t\treturn functionHas, inner\n\t}\n\treturn noFunction, placeholder\n}\n\n// resolveJSONPathPlaceholder handles [BODY].path and [BODY][index] placeholders\nfunc resolveJSONPathPlaceholder(placeholder string, fn functionType, originalPlaceholder string, result *Result) (string, error) {\n\t// Extract the path after [BODY] (case insensitive)\n\tuppercasePlaceholder := strings.ToUpper(placeholder)\n\tpath := \"\"\n\tif strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder) {\n\t\tpath = placeholder[len(BodyPlaceholder):]\n\t} else {\n\t\tpath = strings.TrimPrefix(placeholder, BodyPlaceholder)\n\t}\n\t// Remove leading dot if present\n\tpath = strings.TrimPrefix(path, \".\")\n\tresolvedValue, resolvedLength, err := jsonpath.Eval(path, result.Body)\n\tif fn == functionHas {\n\t\treturn strconv.FormatBool(err == nil), nil\n\t}\n\tif err != nil {\n\t\treturn originalPlaceholder + \" \" + InvalidConditionElementSuffix, nil\n\t}\n\tif fn == functionLen {\n\t\treturn strconv.Itoa(resolvedLength), nil\n\t}\n\treturn resolvedValue, nil\n}\n\n// resolveContextPlaceholder handles [CONTEXT] placeholder resolution\nfunc resolveContextPlaceholder(placeholder string, fn functionType, originalPlaceholder string, ctx *gontext.Gontext) (string, error) {\n\tcontextPath := strings.TrimPrefix(placeholder, ContextPlaceholder)\n\tcontextPath = strings.TrimPrefix(contextPath, \".\")\n\tif contextPath == \"\" {\n\t\tif fn == functionHas {\n\t\t\treturn \"false\", nil\n\t\t}\n\t\treturn originalPlaceholder + \" \" + InvalidConditionElementSuffix, nil\n\t}\n\tvalue, err := ctx.Get(contextPath)\n\tif fn == functionHas {\n\t\treturn strconv.FormatBool(err == nil), nil\n\t}\n\tif err != nil {\n\t\treturn originalPlaceholder + \" \" + InvalidConditionElementSuffix, nil\n\t}\n\tif fn == functionLen {\n\t\tswitch v := value.(type) {\n\t\tcase string:\n\t\t\treturn strconv.Itoa(len(v)), nil\n\t\tcase []interface{}:\n\t\t\treturn strconv.Itoa(len(v)), nil\n\t\tcase map[string]interface{}:\n\t\t\treturn strconv.Itoa(len(v)), nil\n\t\tdefault:\n\t\t\treturn strconv.Itoa(len(fmt.Sprintf(\"%v\", v))), nil\n\t\t}\n\t}\n\treturn fmt.Sprintf(\"%v\", value), nil\n}\n\n// formatWithFunction applies len/has functions to any value\nfunc formatWithFunction(value string, fn functionType) string {\n\tswitch fn {\n\tcase functionHas:\n\t\treturn strconv.FormatBool(value != \"\")\n\tcase functionLen:\n\t\treturn strconv.Itoa(len(value))\n\tdefault:\n\t\treturn value\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/placeholder_test.go",
    "content": "package endpoint\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/gontext\"\n)\n\nfunc TestResolvePlaceholder(t *testing.T) {\n\tresult := &Result{\n\t\tHTTPStatus:            200,\n\t\tIP:                    \"127.0.0.1\",\n\t\tDuration:              250 * time.Millisecond,\n\t\tDNSRCode:              \"NOERROR\",\n\t\tConnected:             true,\n\t\tCertificateExpiration: 30 * 24 * time.Hour,\n\t\tDomainExpiration:      365 * 24 * time.Hour,\n\t\tBody:                  []byte(`{\"status\":\"success\",\"items\":[1,2,3],\"user\":{\"name\":\"john\",\"id\":123}}`),\n\t}\n\n\tctx := gontext.New(map[string]interface{}{\n\t\t\"user_id\":       \"abc123\",\n\t\t\"session_token\": \"xyz789\",\n\t\t\"array_data\":    []interface{}{\"a\", \"b\", \"c\"},\n\t\t\"nested\": map[string]interface{}{\n\t\t\t\"value\": \"test\",\n\t\t},\n\t})\n\n\ttests := []struct {\n\t\tname        string\n\t\tplaceholder string\n\t\texpected    string\n\t}{\n\t\t// Basic placeholders\n\t\t{\"status\", \"[STATUS]\", \"200\"},\n\t\t{\"ip\", \"[IP]\", \"127.0.0.1\"},\n\t\t{\"response-time\", \"[RESPONSE_TIME]\", \"250\"},\n\t\t{\"dns-rcode\", \"[DNS_RCODE]\", \"NOERROR\"},\n\t\t{\"connected\", \"[CONNECTED]\", \"true\"},\n\t\t{\"certificate-expiration\", \"[CERTIFICATE_EXPIRATION]\", \"2592000000\"},\n\t\t{\"domain-expiration\", \"[DOMAIN_EXPIRATION]\", \"31536000000\"},\n\t\t{\"body\", \"[BODY]\", `{\"status\":\"success\",\"items\":[1,2,3],\"user\":{\"name\":\"john\",\"id\":123}}`},\n\n\t\t// Case insensitive placeholders\n\t\t{\"status-lowercase\", \"[status]\", \"200\"},\n\t\t{\"ip-mixed-case\", \"[Ip]\", \"127.0.0.1\"},\n\n\t\t// Function wrappers on basic placeholders\n\t\t{\"len-status\", \"len([STATUS])\", \"3\"},\n\t\t{\"len-ip\", \"len([IP])\", \"9\"},\n\t\t{\"has-status\", \"has([STATUS])\", \"true\"},\n\t\t{\"has-empty\", \"has()\", \"false\"},\n\n\t\t// JSONPath expressions\n\t\t{\"body-status\", \"[BODY].status\", \"success\"},\n\t\t{\"body-user-name\", \"[BODY].user.name\", \"john\"},\n\t\t{\"body-user-id\", \"[BODY].user.id\", \"123\"},\n\t\t{\"len-body-items\", \"len([BODY].items)\", \"3\"},\n\t\t{\"body-array-index\", \"[BODY].items[0]\", \"1\"},\n\t\t{\"has-body-status\", \"has([BODY].status)\", \"true\"},\n\t\t{\"has-body-missing\", \"has([BODY].missing)\", \"false\"},\n\n\t\t// Context placeholders\n\t\t{\"context-user-id\", \"[CONTEXT].user_id\", \"abc123\"},\n\t\t{\"context-session-token\", \"[CONTEXT].session_token\", \"xyz789\"},\n\t\t{\"context-nested\", \"[CONTEXT].nested.value\", \"test\"},\n\t\t{\"len-context-array\", \"len([CONTEXT].array_data)\", \"3\"},\n\t\t{\"has-context-user-id\", \"has([CONTEXT].user_id)\", \"true\"},\n\t\t{\"has-context-missing\", \"has([CONTEXT].missing)\", \"false\"},\n\n\t\t// Invalid placeholders\n\t\t{\"unknown-placeholder\", \"[UNKNOWN]\", \"[UNKNOWN]\"},\n\t\t{\"len-unknown\", \"len([UNKNOWN])\", \"len([UNKNOWN]) (INVALID)\"},\n\t\t{\"has-unknown\", \"has([UNKNOWN])\", \"false\"},\n\t\t{\"invalid-jsonpath\", \"[BODY].invalid.path\", \"[BODY].invalid.path (INVALID)\"},\n\n\t\t// Literal strings\n\t\t{\"literal-string\", \"literal\", \"literal\"},\n\t\t{\"number-string\", \"123\", \"123\"},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tactual, err := ResolvePlaceholder(test.placeholder, result, ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif actual != test.expected {\n\t\t\t\tt.Errorf(\"expected '%s', got '%s'\", test.expected, actual)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolvePlaceholderWithoutContext(t *testing.T) {\n\tresult := &Result{\n\t\tHTTPStatus: 404,\n\t\tBody:       []byte(`{\"error\":\"not found\"}`),\n\t}\n\n\ttests := []struct {\n\t\tname        string\n\t\tplaceholder string\n\t\texpected    string\n\t}{\n\t\t{\"status-without-context\", \"[STATUS]\", \"404\"},\n\t\t{\"body-without-context\", \"[BODY].error\", \"not found\"},\n\t\t{\"context-without-context\", \"[CONTEXT].user_id\", \"[CONTEXT].user_id\"},\n\t\t{\"has-context-without-context\", \"has([CONTEXT].user_id)\", \"false\"},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tactual, err := ResolvePlaceholder(test.placeholder, result, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t}\n\t\t\tif actual != test.expected {\n\t\t\t\tt.Errorf(\"expected '%s', got '%s'\", test.expected, actual)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/result.go",
    "content": "package endpoint\n\nimport (\n\t\"slices\"\n\t\"time\"\n)\n\n// Result of the evaluation of an Endpoint\ntype Result struct {\n\t// HTTPStatus is the HTTP response status code\n\tHTTPStatus int `json:\"status,omitempty\"`\n\n\t// DNSRCode is the response code of a DNS query in a human-readable format\n\t//\n\t// Possible values: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED\n\tDNSRCode string `json:\"-\"`\n\n\t// Hostname extracted from Endpoint.URL\n\tHostname string `json:\"hostname,omitempty\"`\n\n\t// IP resolved from the Endpoint URL\n\tIP string `json:\"-\"`\n\n\t// Connected whether a connection to the host was established successfully\n\tConnected bool `json:\"-\"`\n\n\t// Duration time that the request took\n\tDuration time.Duration `json:\"duration\"`\n\n\t// Errors encountered during the evaluation of the Endpoint's health\n\tErrors []string `json:\"errors,omitempty\"`\n\n\t// ConditionResults are the results of each of the Endpoint's Condition\n\tConditionResults []*ConditionResult `json:\"conditionResults,omitempty\"`\n\n\t// Success whether the result signifies a success or not\n\tSuccess bool `json:\"success\"`\n\n\t// Timestamp when the request was sent\n\tTimestamp time.Time `json:\"timestamp\"`\n\n\t// CertificateExpiration is the duration before the certificate expires\n\tCertificateExpiration time.Duration `json:\"-\"`\n\n\t// DomainExpiration is the duration before the domain expires\n\tDomainExpiration time.Duration `json:\"-\"`\n\n\t// Body is the response body\n\t//\n\t// Note that this field is not persisted in the storage.\n\t// It is used for health evaluation as well as debugging purposes.\n\tBody []byte `json:\"-\"`\n\n\t///////////////////////////////////////////////////////////////////////\n\t// Below is used only for the UI and is not persisted in the storage //\n\t///////////////////////////////////////////////////////////////////////\n\tport string `yaml:\"-\"` // used for endpoints[].ui.hide-port\n\n\t///////////////////////////////////\n\t// BELOW IS ONLY USED FOR SUITES //\n\t///////////////////////////////////\n\t// Name of the endpoint (ONLY USED FOR SUITES)\n\t// Group is not needed because it's inherited from the suite\n\tName string `json:\"name,omitempty\"`\n}\n\n// AddError adds an error to the result's list of errors.\n// It also ensures that there are no duplicates.\nfunc (r *Result) AddError(error string) {\n\tif !slices.Contains(r.Errors, error) {\n\t\tr.Errors = append(r.Errors, error+\"\")\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/result_test.go",
    "content": "package endpoint\n\nimport (\n\t\"testing\"\n)\n\nfunc TestResult_AddError(t *testing.T) {\n\tresult := &Result{}\n\tresult.AddError(\"potato\")\n\tif len(result.Errors) != 1 {\n\t\tt.Error(\"should've had 1 error\")\n\t}\n\tresult.AddError(\"potato\")\n\tif len(result.Errors) != 1 {\n\t\tt.Error(\"should've still had 1 error, because a duplicate error was added\")\n\t}\n\tresult.AddError(\"tomato\")\n\tif len(result.Errors) != 2 {\n\t\tt.Error(\"should've had 2 error\")\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/ssh/ssh.go",
    "content": "package ssh\n\nimport (\n\t\"errors\"\n)\n\nvar (\n\t// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.\n\tErrEndpointWithoutSSHUsername = errors.New(\"you must specify a username for each SSH endpoint\")\n\n\t// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.\n\tErrEndpointWithoutSSHAuth = errors.New(\"you must specify a password or private-key for each SSH endpoint\")\n)\n\ntype Config struct {\n\tUsername   string `yaml:\"username,omitempty\"`\n\tPassword   string `yaml:\"password,omitempty\"`\n\tPrivateKey string `yaml:\"private-key,omitempty\"`\n}\n\n// Validate the SSH configuration\nfunc (cfg *Config) Validate() error {\n\t// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid\n\tif len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {\n\t\treturn nil\n\t}\n\t// If any authentication method is provided (password or private key), a username is required\n\tif len(cfg.Username) == 0 {\n\t\treturn ErrEndpointWithoutSSHUsername\n\t}\n\t// If a username is provided, require at least a password or a private key\n\tif len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {\n\t\treturn ErrEndpointWithoutSSHAuth\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "config/endpoint/ssh/ssh_test.go",
    "content": "package ssh\n\nimport (\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestSSH_validatePasswordCfg(t *testing.T) {\n\tcfg := &Config{}\n\tif err := cfg.Validate(); err != nil {\n\t\tt.Error(\"didn't expect an error\")\n\t}\n\tcfg.Username = \"username\"\n\tif err := cfg.Validate(); err == nil {\n\t\tt.Error(\"expected an error\")\n\t} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {\n\t\tt.Errorf(\"expected error to be '%v', got '%v'\", ErrEndpointWithoutSSHAuth, err)\n\t}\n\tcfg.Password = \"password\"\n\tif err := cfg.Validate(); err != nil {\n\t\tt.Errorf(\"expected no error, got '%v'\", err)\n\t}\n}\n\nfunc TestSSH_validatePrivateKeyCfg(t *testing.T) {\n\tt.Run(\"fail when username missing but private key provided\", func(t *testing.T) {\n\t\tcfg := &Config{PrivateKey: \"-----BEGIN\"}\n\t\tif err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {\n\t\t\tt.Fatalf(\"expected ErrEndpointWithoutSSHUsername, got %v\", err)\n\t\t}\n\t})\n\tt.Run(\"success when username with private key\", func(t *testing.T) {\n\t\tcfg := &Config{Username: \"user\", PrivateKey: \"-----BEGIN\"}\n\t\tif err := cfg.Validate(); err != nil {\n\t\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "config/endpoint/status.go",
    "content": "package endpoint\n\nimport \"github.com/TwiN/gatus/v5/config/key\"\n\n// Status contains the evaluation Results of an Endpoint\n// This is essentially a DTO\ntype Status struct {\n\t// Name of the endpoint\n\tName string `json:\"name,omitempty\"`\n\n\t// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.\n\tGroup string `json:\"group,omitempty\"`\n\n\t// Key of the Endpoint\n\tKey string `json:\"key\"`\n\n\t// Results is the list of endpoint evaluation results\n\tResults []*Result `json:\"results\"`\n\n\t// Events is a list of events\n\tEvents []*Event `json:\"events,omitempty\"`\n\n\t// Uptime information on the endpoint's uptime\n\t//\n\t// Used by the memory store.\n\t//\n\t// To retrieve the uptime between two time, use store.GetUptimeByKey.\n\tUptime *Uptime `json:\"-\"`\n}\n\n// NewStatus creates a new Status\nfunc NewStatus(group, name string) *Status {\n\treturn &Status{\n\t\tName:    name,\n\t\tGroup:   group,\n\t\tKey:     key.ConvertGroupAndNameToKey(group, name),\n\t\tResults: make([]*Result, 0),\n\t\tEvents:  make([]*Event, 0),\n\t\tUptime:  NewUptime(),\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/status_test.go",
    "content": "package endpoint\n\nimport (\n\t\"testing\"\n)\n\nfunc TestNewEndpointStatus(t *testing.T) {\n\tep := &Endpoint{Name: \"name\", Group: \"group\"}\n\tstatus := NewStatus(ep.Group, ep.Name)\n\tif status.Name != ep.Name {\n\t\tt.Errorf(\"expected %s, got %s\", ep.Name, status.Name)\n\t}\n\tif status.Group != ep.Group {\n\t\tt.Errorf(\"expected %s, got %s\", ep.Group, status.Group)\n\t}\n\tif status.Key != \"group_name\" {\n\t\tt.Errorf(\"expected %s, got %s\", \"group_name\", status.Key)\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/ui/ui.go",
    "content": "package ui\n\nimport \"errors\"\n\n// Config is the UI configuration for endpoint.Endpoint\ntype Config struct {\n\t// HideConditions whether to hide the condition results on the UI\n\tHideConditions bool `yaml:\"hide-conditions\"`\n\n\t// HideHostname whether to hide the hostname in the Result\n\tHideHostname bool `yaml:\"hide-hostname\"`\n\n\t// HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token.\n\tHideURL bool `yaml:\"hide-url\"`\n\n\t// HidePort whether to hide the port in the Result\n\tHidePort bool `yaml:\"hide-port\"`\n\n\t// HideErrors whether to hide the errors in the Result\n\tHideErrors bool `yaml:\"hide-errors\"`\n\n\t// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI\n\tDontResolveFailedConditions bool `yaml:\"dont-resolve-failed-conditions\"`\n\n\t// ResolveSuccessfulConditions whether to resolve successful conditions in the Result for display in the UI\n\tResolveSuccessfulConditions bool `yaml:\"resolve-successful-conditions\"`\n\n\t// Badge is the configuration for the badges generated\n\tBadge *Badge `yaml:\"badge\"`\n}\n\ntype Badge struct {\n\tResponseTime *ResponseTime `yaml:\"response-time\"`\n}\n\ntype ResponseTime struct {\n\tThresholds []int `yaml:\"thresholds\"`\n}\n\nvar (\n\tErrInvalidBadgeResponseTimeConfig = errors.New(\"invalid response time badge configuration: expected parameter 'response-time' to have 5 ascending numerical values\")\n)\n\n// ValidateAndSetDefaults validates the UI configuration and sets the default values\nfunc (config *Config) ValidateAndSetDefaults() error {\n\tif config.Badge != nil {\n\t\tif len(config.Badge.ResponseTime.Thresholds) != 5 {\n\t\t\treturn ErrInvalidBadgeResponseTimeConfig\n\t\t}\n\t\tfor i := 4; i > 0; i-- {\n\t\t\tif config.Badge.ResponseTime.Thresholds[i] < config.Badge.ResponseTime.Thresholds[i-1] {\n\t\t\t\treturn ErrInvalidBadgeResponseTimeConfig\n\t\t\t}\n\t\t}\n\t} else {\n\t\tconfig.Badge = GetDefaultConfig().Badge\n\t}\n\treturn nil\n}\n\n// GetDefaultConfig retrieves the default UI configuration\nfunc GetDefaultConfig() *Config {\n\treturn &Config{\n\t\tHideHostname:                false,\n\t\tHideURL:                     false,\n\t\tHidePort:                    false,\n\t\tHideErrors:                  false,\n\t\tDontResolveFailedConditions: false,\n\t\tResolveSuccessfulConditions: false,\n\t\tHideConditions:              false,\n\t\tBadge: &Badge{\n\t\t\tResponseTime: &ResponseTime{\n\t\t\t\tThresholds: []int{50, 200, 300, 500, 750},\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/ui/ui_test.go",
    "content": "package ui\n\nimport (\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestValidateAndSetDefaults(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tconfig  *Config\n\t\twantErr error\n\t}{\n\t\t{\n\t\t\tname: \"with-valid-config\",\n\t\t\tconfig: &Config{\n\t\t\t\tBadge: &Badge{\n\t\t\t\t\tResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500, 750}},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"with-invalid-threshold-length\",\n\t\t\tconfig: &Config{\n\t\t\t\tBadge: &Badge{\n\t\t\t\t\tResponseTime: &ResponseTime{Thresholds: []int{50, 200, 300, 500}},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: ErrInvalidBadgeResponseTimeConfig,\n\t\t},\n\t\t{\n\t\t\tname: \"with-invalid-thresholds-order\",\n\t\t\tconfig: &Config{\n\t\t\t\tBadge: &Badge{ResponseTime: &ResponseTime{Thresholds: []int{50, 200, 500, 300, 750}}},\n\t\t\t},\n\t\t\twantErr: ErrInvalidBadgeResponseTimeConfig,\n\t\t},\n\t\t{\n\t\t\tname:    \"with-no-badge-configured\", // should give default badge cfg\n\t\t\tconfig:  &Config{},\n\t\t\twantErr: nil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif err := tt.config.ValidateAndSetDefaults(); !errors.Is(err, tt.wantErr) {\n\t\t\t\tt.Errorf(\"Expected error %v, got %v\", tt.wantErr, err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "config/endpoint/uptime.go",
    "content": "package endpoint\n\n// Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself\n// and some other statistics\ntype Uptime struct {\n\t// HourlyStatistics is a map containing metrics collected (value) for every hourly unix timestamps (key)\n\t//\n\t// Used only if the storage type is memory\n\tHourlyStatistics map[int64]*HourlyUptimeStatistics `json:\"-\"`\n}\n\n// HourlyUptimeStatistics is a struct containing all metrics collected over the course of an hour\ntype HourlyUptimeStatistics struct {\n\tTotalExecutions             uint64 // Total number of checks\n\tSuccessfulExecutions        uint64 // Number of successful executions\n\tTotalExecutionsResponseTime uint64 // Total response time for all executions in milliseconds\n}\n\n// NewUptime creates a new Uptime\nfunc NewUptime() *Uptime {\n\treturn &Uptime{\n\t\tHourlyStatistics: make(map[int64]*HourlyUptimeStatistics),\n\t}\n}\n"
  },
  {
    "path": "config/gontext/gontext.go",
    "content": "package gontext\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n)\n\nvar (\n\t// ErrGontextPathNotFound is returned when a gontext path doesn't exist\n\tErrGontextPathNotFound = errors.New(\"gontext path not found\")\n)\n\n// Gontext holds values that can be shared between endpoints in a suite\ntype Gontext struct {\n\tmu     sync.RWMutex\n\tvalues map[string]interface{}\n}\n\n// New creates a new gontext with initial values\nfunc New(initial map[string]interface{}) *Gontext {\n\tif initial == nil {\n\t\tinitial = make(map[string]interface{})\n\t}\n\t// Create a deep copy to avoid external modifications\n\tvalues := make(map[string]interface{})\n\tfor k, v := range initial {\n\t\tvalues[k] = deepCopyValue(v)\n\t}\n\treturn &Gontext{\n\t\tvalues: values,\n\t}\n}\n\n// Get retrieves a value from the gontext using dot notation\nfunc (g *Gontext) Get(path string) (interface{}, error) {\n\tg.mu.RLock()\n\tdefer g.mu.RUnlock()\n\tparts := strings.Split(path, \".\")\n\tcurrent := interface{}(g.values)\n\tfor _, part := range parts {\n\t\tswitch v := current.(type) {\n\t\tcase map[string]interface{}:\n\t\t\tval, exists := v[part]\n\t\t\tif !exists {\n\t\t\t\treturn nil, fmt.Errorf(\"%w: %s\", ErrGontextPathNotFound, path)\n\t\t\t}\n\t\t\tcurrent = val\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"%w: %s\", ErrGontextPathNotFound, path)\n\t\t}\n\t}\n\treturn current, nil\n}\n\n// Set stores a value in the gontext using dot notation\nfunc (g *Gontext) Set(path string, value interface{}) error {\n\tg.mu.Lock()\n\tdefer g.mu.Unlock()\n\tparts := strings.Split(path, \".\")\n\tif len(parts) == 0 {\n\t\treturn errors.New(\"empty path\")\n\t}\n\t// Navigate to the parent of the target\n\tcurrent := g.values\n\tfor i := 0; i < len(parts)-1; i++ {\n\t\tpart := parts[i]\n\t\tif next, exists := current[part]; exists {\n\t\t\tif nextMap, ok := next.(map[string]interface{}); ok {\n\t\t\t\tcurrent = nextMap\n\t\t\t} else {\n\t\t\t\t// Path exists but is not a map, create a new map\n\t\t\t\tnewMap := make(map[string]interface{})\n\t\t\t\tcurrent[part] = newMap\n\t\t\t\tcurrent = newMap\n\t\t\t}\n\t\t} else {\n\t\t\t// Create intermediate maps\n\t\t\tnewMap := make(map[string]interface{})\n\t\t\tcurrent[part] = newMap\n\t\t\tcurrent = newMap\n\t\t}\n\t}\n\t// Set the final value\n\tcurrent[parts[len(parts)-1]] = value\n\treturn nil\n}\n\n// GetAll returns a copy of all gontext values\nfunc (g *Gontext) GetAll() map[string]interface{} {\n\tg.mu.RLock()\n\tdefer g.mu.RUnlock()\n\n\tresult := make(map[string]interface{})\n\tfor k, v := range g.values {\n\t\tresult[k] = deepCopyValue(v)\n\t}\n\treturn result\n}\n\n// deepCopyValue creates a deep copy of a value\nfunc deepCopyValue(v interface{}) interface{} {\n\tswitch val := v.(type) {\n\tcase map[string]interface{}:\n\t\tnewMap := make(map[string]interface{})\n\t\tfor k, v := range val {\n\t\t\tnewMap[k] = deepCopyValue(v)\n\t\t}\n\t\treturn newMap\n\tcase []interface{}:\n\t\tnewSlice := make([]interface{}, len(val))\n\t\tfor i, v := range val {\n\t\t\tnewSlice[i] = deepCopyValue(v)\n\t\t}\n\t\treturn newSlice\n\tdefault:\n\t\t// For primitive types, return as-is (they're passed by value anyway)\n\t\treturn val\n\t}\n}\n"
  },
  {
    "path": "config/gontext/gontext_test.go",
    "content": "package gontext\n\nimport (\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestNew(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinitial  map[string]interface{}\n\t\texpected map[string]interface{}\n\t}{\n\t\t{\n\t\t\tname:     \"nil-input\",\n\t\t\tinitial:  nil,\n\t\t\texpected: make(map[string]interface{}),\n\t\t},\n\t\t{\n\t\t\tname:     \"empty-input\",\n\t\t\tinitial:  make(map[string]interface{}),\n\t\t\texpected: make(map[string]interface{}),\n\t\t},\n\t\t{\n\t\t\tname: \"simple-values\",\n\t\t\tinitial: map[string]interface{}{\n\t\t\t\t\"key1\": \"value1\",\n\t\t\t\t\"key2\": 42,\n\t\t\t},\n\t\t\texpected: map[string]interface{}{\n\t\t\t\t\"key1\": \"value1\",\n\t\t\t\t\"key2\": 42,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"nested-values\",\n\t\t\tinitial: map[string]interface{}{\n\t\t\t\t\"user\": map[string]interface{}{\n\t\t\t\t\t\"id\":   123,\n\t\t\t\t\t\"name\": \"John Doe\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: map[string]interface{}{\n\t\t\t\t\"user\": map[string]interface{}{\n\t\t\t\t\t\"id\":   123,\n\t\t\t\t\t\"name\": \"John Doe\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tctx := New(tt.initial)\n\t\t\tif ctx == nil {\n\t\t\t\tt.Error(\"Expected non-nil gontext\")\n\t\t\t}\n\t\t\tif ctx.values == nil {\n\t\t\t\tt.Error(\"Expected non-nil values map\")\n\t\t\t}\n\n\t\t\t// Verify deep copy by modifying original\n\t\t\tif tt.initial != nil {\n\t\t\t\ttt.initial[\"modified\"] = \"should not appear\"\n\t\t\t\tif _, exists := ctx.values[\"modified\"]; exists {\n\t\t\t\t\tt.Error(\"Deep copy failed - original map modification affected gontext\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGontext_Get(t *testing.T) {\n\tctx := New(map[string]interface{}{\n\t\t\"simple\":  \"value\",\n\t\t\"number\":  42,\n\t\t\"boolean\": true,\n\t\t\"nested\": map[string]interface{}{\n\t\t\t\"level1\": map[string]interface{}{\n\t\t\t\t\"level2\": \"deep_value\",\n\t\t\t},\n\t\t},\n\t\t\"user\": map[string]interface{}{\n\t\t\t\"id\":   123,\n\t\t\t\"name\": \"John\",\n\t\t\t\"profile\": map[string]interface{}{\n\t\t\t\t\"email\": \"john@example.com\",\n\t\t\t},\n\t\t},\n\t})\n\n\ttests := []struct {\n\t\tname        string\n\t\tpath        string\n\t\texpected    interface{}\n\t\tshouldError bool\n\t\terrorType   error\n\t}{\n\t\t{\n\t\t\tname:        \"simple-value\",\n\t\t\tpath:        \"simple\",\n\t\t\texpected:    \"value\",\n\t\t\tshouldError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"number-value\",\n\t\t\tpath:        \"number\",\n\t\t\texpected:    42,\n\t\t\tshouldError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"boolean-value\",\n\t\t\tpath:        \"boolean\",\n\t\t\texpected:    true,\n\t\t\tshouldError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"nested-value\",\n\t\t\tpath:        \"nested.level1.level2\",\n\t\t\texpected:    \"deep_value\",\n\t\t\tshouldError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"user-id\",\n\t\t\tpath:        \"user.id\",\n\t\t\texpected:    123,\n\t\t\tshouldError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"deep-nested-value\",\n\t\t\tpath:        \"user.profile.email\",\n\t\t\texpected:    \"john@example.com\",\n\t\t\tshouldError: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"non-existent-key\",\n\t\t\tpath:        \"nonexistent\",\n\t\t\texpected:    nil,\n\t\t\tshouldError: true,\n\t\t\terrorType:   ErrGontextPathNotFound,\n\t\t},\n\t\t{\n\t\t\tname:        \"non-existent-nested-key\",\n\t\t\tpath:        \"user.nonexistent\",\n\t\t\texpected:    nil,\n\t\t\tshouldError: true,\n\t\t\terrorType:   ErrGontextPathNotFound,\n\t\t},\n\t\t{\n\t\t\tname:        \"invalid-nested-path\",\n\t\t\tpath:        \"simple.invalid\",\n\t\t\texpected:    nil,\n\t\t\tshouldError: true,\n\t\t\terrorType:   ErrGontextPathNotFound,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := ctx.Get(tt.path)\n\n\t\t\tif tt.shouldError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error but got none\")\n\t\t\t\t}\n\t\t\t\tif tt.errorType != nil && !errors.Is(err, tt.errorType) {\n\t\t\t\t\tt.Errorf(\"Expected error type %v, got %v\", tt.errorType, err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t\t\t\t}\n\t\t\t\tif result != tt.expected {\n\t\t\t\t\tt.Errorf(\"Expected %v, got %v\", tt.expected, result)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGontext_Set(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tpath    string\n\t\tvalue   interface{}\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:    \"simple-set\",\n\t\t\tpath:    \"key\",\n\t\t\tvalue:   \"value\",\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"nested-set\",\n\t\t\tpath:    \"user.name\",\n\t\t\tvalue:   \"John Doe\",\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"deep-nested-set\",\n\t\t\tpath:    \"user.profile.email\",\n\t\t\tvalue:   \"john@example.com\",\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"override-primitive-with-nested\",\n\t\t\tpath:    \"existing.new\",\n\t\t\tvalue:   \"nested_value\",\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"empty-path\",\n\t\t\tpath:    \"\",\n\t\t\tvalue:   \"value\",\n\t\t\twantErr: false, // Actually, empty string creates a single part [\"\"], which is valid\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tctx := New(map[string]interface{}{\n\t\t\t\t\"existing\": \"primitive\",\n\t\t\t})\n\n\t\t\terr := ctx.Set(tt.path, tt.value)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"Expected error but got none\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Verify the value was set correctly\n\t\t\tresult, getErr := ctx.Get(tt.path)\n\t\t\tif getErr != nil {\n\t\t\t\tt.Errorf(\"Error retrieving set value: %v\", getErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif result != tt.value {\n\t\t\t\tt.Errorf(\"Expected %v, got %v\", tt.value, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGontext_SetOverrideBehavior(t *testing.T) {\n\tctx := New(map[string]interface{}{\n\t\t\"primitive\": \"value\",\n\t\t\"nested\": map[string]interface{}{\n\t\t\t\"key\": \"existing\",\n\t\t},\n\t})\n\n\t// Test overriding primitive with nested structure\n\terr := ctx.Set(\"primitive.new\", \"nested_value\")\n\tif err != nil {\n\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t}\n\n\t// Verify the primitive was replaced with a nested structure\n\tresult, err := ctx.Get(\"primitive.new\")\n\tif err != nil {\n\t\tt.Errorf(\"Error getting nested value: %v\", err)\n\t}\n\tif result != \"nested_value\" {\n\t\tt.Errorf(\"Expected 'nested_value', got %v\", result)\n\t}\n\n\t// Test overriding existing nested value\n\terr = ctx.Set(\"nested.key\", \"modified\")\n\tif err != nil {\n\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t}\n\n\tresult, err = ctx.Get(\"nested.key\")\n\tif err != nil {\n\t\tt.Errorf(\"Error getting modified value: %v\", err)\n\t}\n\tif result != \"modified\" {\n\t\tt.Errorf(\"Expected 'modified', got %v\", result)\n\t}\n}\n\nfunc TestGontext_GetAll(t *testing.T) {\n\tinitial := map[string]interface{}{\n\t\t\"key1\": \"value1\",\n\t\t\"key2\": 42,\n\t\t\"nested\": map[string]interface{}{\n\t\t\t\"inner\": \"value\",\n\t\t},\n\t}\n\n\tctx := New(initial)\n\n\t// Add another value after creation\n\tctx.Set(\"key3\", \"value3\")\n\n\tresult := ctx.GetAll()\n\n\t// Verify all values are present\n\tif result[\"key1\"] != \"value1\" {\n\t\tt.Errorf(\"Expected key1=value1, got %v\", result[\"key1\"])\n\t}\n\tif result[\"key2\"] != 42 {\n\t\tt.Errorf(\"Expected key2=42, got %v\", result[\"key2\"])\n\t}\n\tif result[\"key3\"] != \"value3\" {\n\t\tt.Errorf(\"Expected key3=value3, got %v\", result[\"key3\"])\n\t}\n\n\t// Verify nested values\n\tnested, ok := result[\"nested\"].(map[string]interface{})\n\tif !ok {\n\t\tt.Error(\"Expected nested to be map[string]interface{}\")\n\t} else if nested[\"inner\"] != \"value\" {\n\t\tt.Errorf(\"Expected nested.inner=value, got %v\", nested[\"inner\"])\n\t}\n\n\t// Verify deep copy - modifying returned map shouldn't affect gontext\n\tresult[\"key1\"] = \"modified\"\n\toriginal, _ := ctx.Get(\"key1\")\n\tif original != \"value1\" {\n\t\tt.Error(\"GetAll did not return a deep copy - modification affected original\")\n\t}\n}\n\nfunc TestGontext_ConcurrentAccess(t *testing.T) {\n\tctx := New(map[string]interface{}{\n\t\t\"counter\": 0,\n\t})\n\n\tdone := make(chan bool, 10)\n\n\t// Start 5 goroutines that read values\n\tfor i := range 5 {\n\t\tgo func(id int) {\n\t\t\tfor range 100 {\n\t\t\t\t_, err := ctx.Get(\"counter\")\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Reader %d error: %v\", id, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tdone <- true\n\t\t}(i)\n\t}\n\n\t// Start 5 goroutines that write values\n\tfor i := range 5 {\n\t\tgo func(id int) {\n\t\t\tfor j := range 100 {\n\t\t\t\terr := ctx.Set(\"counter\", id*1000+j)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Writer %d error: %v\", id, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tdone <- true\n\t\t}(i)\n\t}\n\n\t// Wait for all goroutines to complete\n\tfor range 10 {\n\t\t<-done\n\t}\n}\n\nfunc TestDeepCopyValue(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tinput interface{}\n\t}{\n\t\t{\n\t\t\tname:  \"primitive-string\",\n\t\t\tinput: \"test\",\n\t\t},\n\t\t{\n\t\t\tname:  \"primitive-int\",\n\t\t\tinput: 42,\n\t\t},\n\t\t{\n\t\t\tname:  \"primitive-bool\",\n\t\t\tinput: true,\n\t\t},\n\t\t{\n\t\t\tname: \"simple-map\",\n\t\t\tinput: map[string]interface{}{\n\t\t\t\t\"key\": \"value\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"nested-map\",\n\t\t\tinput: map[string]interface{}{\n\t\t\t\t\"nested\": map[string]interface{}{\n\t\t\t\t\t\"deep\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"simple-slice\",\n\t\t\tinput: []interface{}{\"a\", \"b\", \"c\"},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed-slice\",\n\t\t\tinput: []interface{}{\n\t\t\t\t\"string\",\n\t\t\t\t42,\n\t\t\t\tmap[string]interface{}{\"nested\": \"value\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := deepCopyValue(tt.input)\n\n\t\t\t// For maps and slices, verify it's a different object\n\t\t\tswitch v := tt.input.(type) {\n\t\t\tcase map[string]interface{}:\n\t\t\t\tresultMap, ok := result.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Error(\"Deep copy didn't preserve map type\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Modify original to ensure independence\n\t\t\t\tv[\"modified\"] = \"test\"\n\t\t\t\tif _, exists := resultMap[\"modified\"]; exists {\n\t\t\t\t\tt.Error(\"Deep copy failed - maps are not independent\")\n\t\t\t\t}\n\t\t\tcase []interface{}:\n\t\t\t\tresultSlice, ok := result.([]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Error(\"Deep copy didn't preserve slice type\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(resultSlice) != len(v) {\n\t\t\t\t\tt.Error(\"Deep copy didn't preserve slice length\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "config/key/key.go",
    "content": "package key\n\nimport \"strings\"\n\n// ConvertGroupAndNameToKey converts a group and a name to a key\nfunc ConvertGroupAndNameToKey(groupName, name string) string {\n\treturn sanitize(groupName) + \"_\" + sanitize(name)\n}\n\nfunc sanitize(s string) string {\n\ts = strings.TrimSpace(strings.ToLower(s))\n\ts = strings.ReplaceAll(s, \"/\", \"-\")\n\ts = strings.ReplaceAll(s, \"_\", \"-\")\n\ts = strings.ReplaceAll(s, \".\", \"-\")\n\ts = strings.ReplaceAll(s, \",\", \"-\")\n\ts = strings.ReplaceAll(s, \" \", \"-\")\n\ts = strings.ReplaceAll(s, \"#\", \"-\")\n\ts = strings.ReplaceAll(s, \"+\", \"-\")\n\ts = strings.ReplaceAll(s, \"&\", \"-\")\n\treturn s\n}\n"
  },
  {
    "path": "config/key/key_bench_test.go",
    "content": "package key\n\nimport (\n\t\"testing\"\n)\n\nfunc BenchmarkConvertGroupAndNameToKey(b *testing.B) {\n\tfor n := 0; n < b.N; n++ {\n\t\tConvertGroupAndNameToKey(\"group\", \"name\")\n\t}\n}"
  },
  {
    "path": "config/key/key_test.go",
    "content": "package key\n\nimport \"testing\"\n\nfunc TestConvertGroupAndNameToKey(t *testing.T) {\n\ttype Scenario struct {\n\t\tGroupName      string\n\t\tName           string\n\t\tExpectedOutput string\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tGroupName:      \"Core\",\n\t\t\tName:           \"Front End\",\n\t\t\tExpectedOutput: \"core_front-end\",\n\t\t},\n\t\t{\n\t\t\tGroupName:      \"Load balancers\",\n\t\t\tName:           \"us-west-2\",\n\t\t\tExpectedOutput: \"load-balancers_us-west-2\",\n\t\t},\n\t\t{\n\t\t\tGroupName:      \"a/b test\",\n\t\t\tName:           \"a\",\n\t\t\tExpectedOutput: \"a-b-test_a\",\n\t\t},\n\t\t{\n\t\t\tGroupName:      \"\",\n\t\t\tName:           \"name\",\n\t\t\tExpectedOutput: \"_name\",\n\t\t},\n\t\t{\n\t\t\tGroupName:      \"API (v1)\",\n\t\t\tName:           \"endpoint\",\n\t\t\tExpectedOutput: \"api-(v1)_endpoint\",\n\t\t},\n\t\t{\n\t\t\tGroupName:      \"website (admin)\",\n\t\t\tName:           \"test\",\n\t\t\tExpectedOutput: \"website-(admin)_test\",\n\t\t},\n\t\t{\n\t\t\tGroupName:      \"search\",\n\t\t\tName:           \"query&filter\",\n\t\t\tExpectedOutput: \"search_query-filter\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.ExpectedOutput, func(t *testing.T) {\n\t\t\toutput := ConvertGroupAndNameToKey(scenario.GroupName, scenario.Name)\n\t\t\tif output != scenario.ExpectedOutput {\n\t\t\t\tt.Errorf(\"expected '%s', got '%s'\", scenario.ExpectedOutput, output)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "config/maintenance/maintenance.go",
    "content": "package maintenance\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\t_ \"time/tzdata\" // Required for IANA timezone support\n)\n\nvar (\n\terrInvalidMaintenanceStartFormat = errors.New(\"invalid maintenance start format: must be hh:mm, between 00:00 and 23:59 inclusively (e.g. 23:00)\")\n\terrInvalidMaintenanceDuration    = errors.New(\"invalid maintenance duration: must be bigger than 0 (e.g. 30m)\")\n\terrInvalidDayName                = fmt.Errorf(\"invalid value specified for 'on'. supported values are %s\", longDayNames)\n\terrInvalidTimezone               = errors.New(\"invalid timezone specified or format not supported. Use IANA timezone format (e.g. America/Sao_Paulo)\")\n\n\tlongDayNames = []string{\n\t\t\"Sunday\",\n\t\t\"Monday\",\n\t\t\"Tuesday\",\n\t\t\"Wednesday\",\n\t\t\"Thursday\",\n\t\t\"Friday\",\n\t\t\"Saturday\",\n\t}\n)\n\n// Config allows for the configuration of a maintenance period.\n// During this maintenance period, no alerts will be sent.\n//\n// Uses UTC by default.\ntype Config struct {\n\tEnabled  *bool         `yaml:\"enabled\"`            // Whether the maintenance period is enabled. Enabled by default if nil.\n\tStart    string        `yaml:\"start,omitempty\"`    // Time at which the maintenance period starts (e.g. 23:00)\n\tDuration time.Duration `yaml:\"duration,omitempty\"` // Duration of the maintenance period (e.g. 4h)\n\tTimezone string        `yaml:\"timezone,omitempty\"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)\n\n\t// Every is a list of days of the week during which maintenance period applies.\n\t// See longDayNames for list of valid values.\n\t// Every day if empty.\n\tEvery []string `yaml:\"every,omitempty\"`\n\n\ttimezoneLocation            *time.Location\n\tdurationToStartFromMidnight time.Duration\n}\n\nfunc GetDefaultConfig() *Config {\n\tdefaultValue := false\n\treturn &Config{\n\t\tEnabled: &defaultValue,\n\t}\n}\n\n// IsEnabled returns whether maintenance is enabled or not\nfunc (c *Config) IsEnabled() bool {\n\tif c.Enabled == nil {\n\t\treturn true\n\t}\n\treturn *c.Enabled\n}\n\n// ValidateAndSetDefaults validates the maintenance configuration and sets the default values if necessary.\n//\n// Must be called once in the application's lifecycle before IsUnderMaintenance is called, since it\n// also sets durationToStartFromMidnight.\nfunc (c *Config) ValidateAndSetDefaults() error {\n\tif c == nil || !c.IsEnabled() {\n\t\t// Don't waste time validating if maintenance is not enabled.\n\t\treturn nil\n\t}\n\tfor _, day := range c.Every {\n\t\tisDayValid := slices.Contains(longDayNames, day)\n\t\tif !isDayValid {\n\t\t\treturn errInvalidDayName\n\t\t}\n\t}\n\tvar err error\n\tc.durationToStartFromMidnight, err = hhmmToDuration(c.Start)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif c.Duration <= 0 || c.Duration > 24*time.Hour {\n\t\treturn errInvalidMaintenanceDuration\n\t}\n\tif c.Timezone != \"\" {\n\t\tc.timezoneLocation, err = time.LoadLocation(c.Timezone)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"%w: %w\", errInvalidTimezone, err)\n\t\t}\n\t} else {\n\t\tc.Timezone = \"UTC\"\n\t\tc.timezoneLocation = time.UTC\n\t}\n\treturn nil\n}\n\n// IsUnderMaintenance checks whether the endpoints that Gatus monitors are within the configured maintenance window\nfunc (c *Config) IsUnderMaintenance() bool {\n\tif !c.IsEnabled() {\n\t\treturn false\n\t}\n\tnow := time.Now()\n\tif c.timezoneLocation != nil {\n\t\tnow = now.In(c.timezoneLocation)\n\t}\n\tadjustedDate := now.Day()\n\tif now.Hour() < int(c.durationToStartFromMidnight.Hours()) {\n\t\t// if time in maintenance window is later than now, treat it as yesterday\n\t\tadjustedDate--\n\t}\n\t// Set to midnight prior to adding duration\n\tdayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location())\n\thasMaintenanceEveryDay := len(c.Every) == 0\n\thasMaintenancePeriodScheduledToStartOnThatWeekday := slices.Contains(c.Every, dayWhereMaintenancePeriodWouldStart.Weekday().String())\n\tif !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {\n\t\t// The day when the maintenance period would start is not scheduled\n\t\t// to have any maintenance, so we can just return false.\n\t\treturn false\n\t}\n\tstartOfMaintenancePeriod := dayWhereMaintenancePeriodWouldStart.Add(c.durationToStartFromMidnight)\n\tendOfMaintenancePeriod := startOfMaintenancePeriod.Add(c.Duration)\n\treturn now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)\n}\n\nfunc hhmmToDuration(s string) (time.Duration, error) {\n\tif len(s) != 5 {\n\t\treturn 0, errInvalidMaintenanceStartFormat\n\t}\n\tvar hours, minutes int\n\tvar err error\n\tif hours, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[:2]); err != nil {\n\t\treturn 0, err\n\t}\n\tif minutes, err = extractNumericalValueFromPotentiallyZeroPaddedString(s[3:5]); err != nil {\n\t\treturn 0, err\n\t}\n\tduration := (time.Duration(hours) * time.Hour) + (time.Duration(minutes) * time.Minute)\n\tif hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || duration < 0 || duration >= 24*time.Hour {\n\t\treturn 0, errInvalidMaintenanceStartFormat\n\t}\n\treturn duration, nil\n}\n\nfunc extractNumericalValueFromPotentiallyZeroPaddedString(s string) (int, error) {\n\treturn strconv.Atoi(strings.TrimPrefix(s, \"0\"))\n}\n"
  },
  {
    "path": "config/maintenance/maintenance_test.go",
    "content": "package maintenance\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestGetDefaultConfig(t *testing.T) {\n\tif *GetDefaultConfig().Enabled {\n\t\tt.Fatal(\"expected default config to be disabled by default\")\n\t}\n}\n\nfunc TestConfig_ValidateAndSetDefaults(t *testing.T) {\n\tyes, no := true, false\n\tscenarios := []struct {\n\t\tname          string\n\t\tcfg           *Config\n\t\texpectedError error\n\t}{\n\t\t{\n\t\t\tname:          \"nil\",\n\t\t\tcfg:           nil,\n\t\t\texpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"disabled\",\n\t\t\tcfg: &Config{\n\t\t\t\tEnabled: &no,\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-day\",\n\t\t\tcfg: &Config{\n\t\t\t\tEvery: []string{\"invalid-day\"},\n\t\t\t},\n\t\t\texpectedError: errInvalidDayName,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-day\",\n\t\t\tcfg: &Config{\n\t\t\t\tEvery: []string{\"invalid-day\"},\n\t\t\t},\n\t\t\texpectedError: errInvalidDayName,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-start-format\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart: \"0000\",\n\t\t\t},\n\t\t\texpectedError: errInvalidMaintenanceStartFormat,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-start-hours\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart: \"25:00\",\n\t\t\t},\n\t\t\texpectedError: errInvalidMaintenanceStartFormat,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-start-minutes\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart: \"0:61\",\n\t\t\t},\n\t\t\texpectedError: errInvalidMaintenanceStartFormat,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-start-minutes-non-numerical\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart: \"00:zz\",\n\t\t\t},\n\t\t\texpectedError: strconv.ErrSyntax,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-start-hours-non-numerical\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart: \"zz:00\",\n\t\t\t},\n\t\t\texpectedError: strconv.ErrSyntax,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-duration\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    \"23:00\",\n\t\t\t\tDuration: 0,\n\t\t\t},\n\t\t\texpectedError: errInvalidMaintenanceDuration,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid-timezone\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    \"23:00\",\n\t\t\t\tDuration: time.Hour,\n\t\t\t\tTimezone: \"invalid-timezone\",\n\t\t\t},\n\t\t\texpectedError: errInvalidTimezone,\n\t\t},\n\t\t{\n\t\t\tname: \"every-day-at-2300\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    \"23:00\",\n\t\t\t\tDuration: time.Hour,\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"every-day-explicitly-at-2300\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    \"23:00\",\n\t\t\t\tDuration: time.Hour,\n\t\t\t\tEvery:    []string{\"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\"},\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"every-monday-at-0000\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    \"00:00\",\n\t\t\t\tDuration: 30 * time.Minute,\n\t\t\t\tEvery:    []string{\"Monday\"},\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"every-friday-and-sunday-at-0000-explicitly-enabled\",\n\t\t\tcfg: &Config{\n\t\t\t\tEnabled:  &yes,\n\t\t\t\tStart:    \"08:00\",\n\t\t\t\tDuration: 8 * time.Hour,\n\t\t\t\tEvery:    []string{\"Friday\", \"Sunday\"},\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"timezone-amsterdam\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    \"23:00\",\n\t\t\t\tDuration: time.Hour,\n\t\t\t\tTimezone: \"Europe/Amsterdam\",\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"timezone-cet\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    \"23:00\",\n\t\t\t\tDuration: time.Hour,\n\t\t\t\tTimezone: \"CET\",\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"timezone-etc-plus-5\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    \"23:00\",\n\t\t\t\tDuration: time.Hour,\n\t\t\t\tTimezone: \"Etc/GMT+5\",\n\t\t\t},\n\t\t\texpectedError: nil,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.cfg.ValidateAndSetDefaults()\n\t\t\tif !errors.Is(err, scenario.expectedError) {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.expectedError, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_IsUnderMaintenance(t *testing.T) {\n\tyes, no := true, false\n\tnow := time.Now().UTC()\n\tscenarios := []struct {\n\t\tname                     string\n\t\tcfg                      *Config\n\t\texpectedUnderMaintenance bool\n\t}{\n\t\t{\n\t\t\tname: \"disabled\",\n\t\t\tcfg: &Config{\n\t\t\t\tEnabled: &no,\n\t\t\t},\n\t\t\texpectedUnderMaintenance: false,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-explicitly-enabled\",\n\t\t\tcfg: &Config{\n\t\t\t\tEnabled:  &yes,\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", now.Hour()),\n\t\t\t\tDuration: 2 * time.Hour,\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-starting-now-for-2h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", now.Hour()),\n\t\t\t\tDuration: 2 * time.Hour,\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-starting-now-for-8h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", now.Hour()),\n\t\t\t\tDuration: 8 * time.Hour,\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-starting-now-for-8h-explicit-days\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", now.Hour()),\n\t\t\t\tDuration: 8 * time.Hour,\n\t\t\t\tEvery:    []string{\"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\"},\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-starting-now-for-23h-explicit-days\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", now.Hour()),\n\t\t\t\tDuration: 23 * time.Hour,\n\t\t\t\tEvery:    []string{\"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\"},\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-starting-4h-ago-for-8h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", normalizeHour(now.Hour()-4)),\n\t\t\t\tDuration: 8 * time.Hour,\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-starting-22h-ago-for-23h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", normalizeHour(now.Hour()-22)),\n\t\t\t\tDuration: 23 * time.Hour,\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-starting-22h-ago-for-24h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", normalizeHour(now.Hour()-22)),\n\t\t\t\tDuration: 24 * time.Hour,\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-amsterdam-timezone-starting-now-for-2h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", inTimezone(now, \"Europe/Amsterdam\", t).Hour()),\n\t\t\t\tDuration: 2 * time.Hour,\n\t\t\t\tTimezone: \"Europe/Amsterdam\",\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-perth-timezone-starting-now-for-2h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", inTimezone(now, \"Australia/Perth\", t).Hour()),\n\t\t\t\tDuration: 2 * time.Hour,\n\t\t\t\tTimezone: \"Australia/Perth\",\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"not-under-maintenance-los-angeles-timezone-starting-now-for-2h-today\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", now.Hour()),\n\t\t\t\tDuration: 2 * time.Hour,\n\t\t\t\tTimezone: \"America/Los_Angeles\",\n\t\t\t\tEvery:    []string{now.Weekday().String()},\n\t\t\t},\n\t\t\texpectedUnderMaintenance: false,\n\t\t},\n\t\t{\n\t\t\tname: \"under-maintenance-utc-timezone-starting-now-for-2h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", now.Hour()),\n\t\t\t\tDuration: 2 * time.Hour,\n\t\t\t\tTimezone: \"UTC\",\n\t\t\t},\n\t\t\texpectedUnderMaintenance: true,\n\t\t},\n\t\t{\n\t\t\tname: \"not-under-maintenance-starting-4h-ago-for-3h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", normalizeHour(now.Hour()-4)),\n\t\t\t\tDuration: 3 * time.Hour,\n\t\t\t},\n\t\t\texpectedUnderMaintenance: false,\n\t\t},\n\t\t{\n\t\t\tname: \"not-under-maintenance-starting-5h-ago-for-1h\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", normalizeHour(now.Hour()-5)),\n\t\t\t\tDuration: time.Hour,\n\t\t\t},\n\t\t\texpectedUnderMaintenance: false,\n\t\t},\n\t\t{\n\t\t\tname: \"not-under-maintenance-today\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", now.Hour()),\n\t\t\t\tDuration: time.Hour,\n\t\t\t\tEvery:    []string{now.Add(48 * time.Hour).Weekday().String()},\n\t\t\t},\n\t\t\texpectedUnderMaintenance: false,\n\t\t},\n\t\t{\n\t\t\tname: \"not-under-maintenance-today-with-24h-duration\",\n\t\t\tcfg: &Config{\n\t\t\t\tStart:    fmt.Sprintf(\"%02d:00\", now.Hour()),\n\t\t\t\tDuration: 24 * time.Hour,\n\t\t\t\tEvery:    []string{now.Add(48 * time.Hour).Weekday().String()},\n\t\t\t},\n\t\t\texpectedUnderMaintenance: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tt.Log(scenario.cfg.Start)\n\t\t\tt.Log(now)\n\t\t\tif err := scenario.cfg.ValidateAndSetDefaults(); err != nil {\n\t\t\t\tt.Fatal(\"validation shouldn't have returned an error, got\", err)\n\t\t\t}\n\t\t\tisUnderMaintenance := scenario.cfg.IsUnderMaintenance()\n\t\t\tif isUnderMaintenance != scenario.expectedUnderMaintenance {\n\t\t\t\tt.Errorf(\"expectedUnderMaintenance %v, got %v\", scenario.expectedUnderMaintenance, isUnderMaintenance)\n\t\t\t\tt.Logf(\"start=%v; duration=%v; now=%v\", scenario.cfg.Start, scenario.cfg.Duration, time.Now().UTC())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc normalizeHour(hour int) int {\n\tif hour < 0 {\n\t\treturn hour + 24\n\t}\n\treturn hour\n}\n\nfunc inTimezone(passedTime time.Time, timezone string, t *testing.T) time.Time {\n\ttimezoneLocation, err := time.LoadLocation(timezone)\n\tif err != nil {\n\t\tt.Fatalf(\"timezone %s did not load\", timezone)\n\t}\n\treturn passedTime.In(timezoneLocation)\n}\n"
  },
  {
    "path": "config/remote/remote.go",
    "content": "package remote\n\nimport (\n\t\"github.com/TwiN/gatus/v5/client\"\n\t\"github.com/TwiN/logr\"\n)\n\n// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.\n// For more information, see https://github.com/TwiN/gatus/issues/64\n\ntype Config struct {\n\t// Instances is a list of remote instances to retrieve endpoint statuses from.\n\tInstances []Instance `yaml:\"instances,omitempty\"`\n\n\t// ClientConfig is the configuration of the client used to communicate with the provider's target\n\tClientConfig *client.Config `yaml:\"client,omitempty\"`\n}\n\ntype Instance struct {\n\tEndpointPrefix string `yaml:\"endpoint-prefix\"`\n\tURL            string `yaml:\"url\"`\n}\n\nfunc (c *Config) ValidateAndSetDefaults() error {\n\tif c.ClientConfig == nil {\n\t\tc.ClientConfig = client.GetDefaultConfig()\n\t} else {\n\t\tif err := c.ClientConfig.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif len(c.Instances) > 0 {\n\t\tlogr.Warn(\"WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.\")\n\t\tlogr.Warn(\"WARNING: See https://github.com/TwiN/gatus/issues/64 for more information\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "config/suite/result.go",
    "content": "package suite\n\nimport (\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\n// Result represents the result of a suite execution\ntype Result struct {\n\t// Name of the suite\n\tName string `json:\"name,omitempty\"`\n\n\t// Group of the suite\n\tGroup string `json:\"group,omitempty\"`\n\n\t// Success indicates whether all required endpoints succeeded\n\tSuccess bool `json:\"success\"`\n\n\t// Timestamp is when the suite execution started\n\tTimestamp time.Time `json:\"timestamp\"`\n\n\t// Duration is how long the entire suite execution took\n\tDuration time.Duration `json:\"duration\"`\n\n\t// EndpointResults contains the results of each endpoint execution\n\tEndpointResults []*endpoint.Result `json:\"endpointResults\"`\n\n\t// Context is the final state of the context after all endpoints executed\n\tContext map[string]interface{} `json:\"-\"`\n\n\t// Errors contains any suite-level errors\n\tErrors []string `json:\"errors,omitempty\"`\n}\n\n// AddError adds an error to the suite result\nfunc (r *Result) AddError(err string) {\n\tr.Errors = append(r.Errors, err)\n}\n\n// CalculateSuccess determines if the suite execution was successful\nfunc (r *Result) CalculateSuccess() {\n\tr.Success = true\n\t// Check if any endpoints failed (all endpoints are required)\n\tfor _, epResult := range r.EndpointResults {\n\t\tif !epResult.Success {\n\t\t\tr.Success = false\n\t\t\tbreak\n\t\t}\n\t}\n\t// Also check for suite-level errors\n\tif len(r.Errors) > 0 {\n\t\tr.Success = false\n\t}\n}\n"
  },
  {
    "path": "config/suite/suite.go",
    "content": "package suite\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/gontext\"\n\t\"github.com/TwiN/gatus/v5/config/key\"\n)\n\nvar (\n\t// ErrSuiteWithNoName is the error returned when a suite has no name\n\tErrSuiteWithNoName = errors.New(\"suite must have a name\")\n\n\t// ErrSuiteWithNoEndpoints is the error returned when a suite has no endpoints\n\tErrSuiteWithNoEndpoints = errors.New(\"suite must have at least one endpoint\")\n\n\t// ErrSuiteWithDuplicateEndpointNames is the error returned when a suite has duplicate endpoint names\n\tErrSuiteWithDuplicateEndpointNames = errors.New(\"suite cannot have duplicate endpoint names\")\n\n\t// ErrSuiteWithInvalidTimeout is the error returned when a suite has an invalid timeout\n\tErrSuiteWithInvalidTimeout = errors.New(\"suite timeout must be positive\")\n\n\t// DefaultInterval is the default interval for suite execution\n\tDefaultInterval = 10 * time.Minute\n\n\t// DefaultTimeout is the default timeout for suite execution\n\tDefaultTimeout = 5 * time.Minute\n)\n\n// Suite is a collection of endpoints that are executed sequentially with shared context\ntype Suite struct {\n\t// Name of the suite. Must be unique.\n\tName string `yaml:\"name\"`\n\n\t// Group the suite belongs to. Used for grouping multiple suites together.\n\tGroup string `yaml:\"group,omitempty\"`\n\n\t// Enabled defines whether the suite is enabled\n\tEnabled *bool `yaml:\"enabled,omitempty\"`\n\n\t// Interval is the duration to wait between suite executions\n\tInterval time.Duration `yaml:\"interval,omitempty\"`\n\n\t// Timeout is the maximum duration for the entire suite execution\n\tTimeout time.Duration `yaml:\"timeout,omitempty\"`\n\n\t// InitialContext holds initial values that can be referenced by endpoints\n\tInitialContext map[string]interface{} `yaml:\"context,omitempty\"`\n\n\t// Endpoints in the suite (executed sequentially)\n\tEndpoints []*endpoint.Endpoint `yaml:\"endpoints\"`\n}\n\n// IsEnabled returns whether the suite is enabled\nfunc (s *Suite) IsEnabled() bool {\n\tif s.Enabled == nil {\n\t\treturn true\n\t}\n\treturn *s.Enabled\n}\n\n// Key returns a unique key for the suite\nfunc (s *Suite) Key() string {\n\treturn key.ConvertGroupAndNameToKey(s.Group, s.Name)\n}\n\n// ValidateAndSetDefaults validates the suite configuration and sets default values\nfunc (s *Suite) ValidateAndSetDefaults() error {\n\t// Validate name\n\tif len(s.Name) == 0 {\n\t\treturn ErrSuiteWithNoName\n\t}\n\t// Validate endpoints\n\tif len(s.Endpoints) == 0 {\n\t\treturn ErrSuiteWithNoEndpoints\n\t}\n\t// Check for duplicate endpoint names\n\tendpointNames := make(map[string]bool)\n\tfor _, ep := range s.Endpoints {\n\t\tif endpointNames[ep.Name] {\n\t\t\treturn fmt.Errorf(\"%w: duplicate endpoint name '%s'\", ErrSuiteWithDuplicateEndpointNames, ep.Name)\n\t\t}\n\t\tendpointNames[ep.Name] = true\n\t\t// Suite endpoints inherit the group from the suite\n\t\tep.Group = s.Group\n\t\t// Validate each endpoint\n\t\tif err := ep.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid endpoint '%s': %w\", ep.Name, err)\n\t\t}\n\t}\n\t// Set default interval\n\tif s.Interval == 0 {\n\t\ts.Interval = DefaultInterval\n\t}\n\t// Set default timeout\n\tif s.Timeout == 0 {\n\t\ts.Timeout = DefaultTimeout\n\t}\n\t// Validate timeout\n\tif s.Timeout < 0 {\n\t\treturn ErrSuiteWithInvalidTimeout\n\t}\n\t// Initialize context if nil\n\tif s.InitialContext == nil {\n\t\ts.InitialContext = make(map[string]interface{})\n\t}\n\treturn nil\n}\n\n// Execute executes all endpoints in the suite sequentially with context sharing\nfunc (s *Suite) Execute() *Result {\n\tstart := time.Now()\n\t// Initialize context from suite configuration\n\tctx := gontext.New(s.InitialContext)\n\t// Create suite result\n\tresult := &Result{\n\t\tName:            s.Name,\n\t\tGroup:           s.Group,\n\t\tSuccess:         true,\n\t\tTimestamp:       start,\n\t\tEndpointResults: make([]*endpoint.Result, 0, len(s.Endpoints)),\n\t}\n\t// Set up timeout for the entire suite execution\n\ttimeoutChan := time.After(s.Timeout)\n\t// Execute each endpoint sequentially\n\tsuiteHasFailed := false\n\tfor _, ep := range s.Endpoints {\n\t\t// Skip non-always-run endpoints if suite has already failed\n\t\tif suiteHasFailed && !ep.AlwaysRun {\n\t\t\tcontinue\n\t\t}\n\t\t// Check timeout\n\t\tselect {\n\t\tcase <-timeoutChan:\n\t\t\tresult.AddError(fmt.Sprintf(\"suite execution timed out after %v\", s.Timeout))\n\t\t\tresult.Success = false\n\t\t\tbreak\n\t\tdefault:\n\t\t}\n\t\t// Execute endpoint with context\n\t\tepStartTime := time.Now()\n\t\tepResult := ep.EvaluateHealthWithContext(ctx)\n\t\tepDuration := time.Since(epStartTime)\n\t\t// Set endpoint name, timestamp, and duration on the result\n\t\tepResult.Name = ep.Name\n\t\tepResult.Timestamp = epStartTime\n\t\tepResult.Duration = epDuration\n\t\t// Store values from the endpoint result if configured (always store, even on failure)\n\t\tif ep.Store != nil {\n\t\t\t_, err := StoreResultValues(ctx, ep.Store, epResult)\n\t\t\tif err != nil {\n\t\t\t\tepResult.AddError(fmt.Sprintf(\"failed to store values: %v\", err))\n\t\t\t}\n\t\t}\n\t\tresult.EndpointResults = append(result.EndpointResults, epResult)\n\t\t// Mark suite as failed on any endpoint failure\n\t\tif !epResult.Success {\n\t\t\tresult.Success = false\n\t\t\tsuiteHasFailed = true\n\t\t}\n\t}\n\tresult.Context = ctx.GetAll()\n\tresult.Duration = time.Since(start)\n\tresult.CalculateSuccess()\n\treturn result\n}\n\n// StoreResultValues extracts values from an endpoint result and stores them in the gontext\nfunc StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result *endpoint.Result) (map[string]interface{}, error) {\n\tif mappings == nil || len(mappings) == 0 {\n\t\treturn nil, nil\n\t}\n\tstoredValues := make(map[string]interface{})\n\tvar extractionErrors []string\n\tfor contextKey, placeholder := range mappings {\n\t\tvalue, err := extractValueForStorage(placeholder, result)\n\t\tif err != nil {\n\t\t\t// Continue storing other values even if one fails\n\t\t\textractionErrors = append(extractionErrors, fmt.Sprintf(\"%s: %v\", contextKey, err))\n\t\t\tstoredValues[contextKey] = fmt.Sprintf(\"ERROR: %v\", err)\n\t\t\tcontinue\n\t\t}\n\t\tif err := ctx.Set(contextKey, value); err != nil {\n\t\t\treturn storedValues, fmt.Errorf(\"failed to store %s: %w\", contextKey, err)\n\t\t}\n\t\tstoredValues[contextKey] = value\n\t}\n\t// Return an error if any values failed to extract\n\tif len(extractionErrors) > 0 {\n\t\treturn storedValues, fmt.Errorf(\"failed to extract values: %s\", strings.Join(extractionErrors, \"; \"))\n\t}\n\treturn storedValues, nil\n}\n\n// extractValueForStorage extracts a value from an endpoint result for storage in context\nfunc extractValueForStorage(placeholder string, result *endpoint.Result) (interface{}, error) {\n\t// Use the unified ResolvePlaceholder function (no context needed for extraction)\n\tresolved, err := endpoint.ResolvePlaceholder(placeholder, result, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// Check if the resolution resulted in an INVALID placeholder\n\t// This happens when a path doesn't exist (e.g., [BODY].nonexistent)\n\tif strings.HasSuffix(resolved, \" \"+endpoint.InvalidConditionElementSuffix) {\n\t\treturn nil, fmt.Errorf(\"invalid path: %s\", strings.TrimSuffix(resolved, \" \"+endpoint.InvalidConditionElementSuffix))\n\t}\n\t// Try to parse as number or boolean to store as proper types\n\t// Try int first for whole numbers\n\tif num, err := strconv.ParseInt(resolved, 10, 64); err == nil {\n\t\treturn num, nil\n\t}\n\t// Then try float for decimals\n\tif num, err := strconv.ParseFloat(resolved, 64); err == nil {\n\t\treturn num, nil\n\t}\n\t// Then try boolean\n\tif boolVal, err := strconv.ParseBool(resolved); err == nil {\n\t\treturn boolVal, nil\n\t}\n\treturn resolved, nil\n}\n"
  },
  {
    "path": "config/suite/suite_status.go",
    "content": "package suite\n\n// Status represents the status of a suite\ntype Status struct {\n\t// Name of the suite\n\tName string `json:\"name,omitempty\"`\n\n\t// Group the suite is a part of. Used for grouping multiple suites together on the front end.\n\tGroup string `json:\"group,omitempty\"`\n\n\t// Key of the Suite\n\tKey string `json:\"key\"`\n\n\t// Results is the list of suite execution results\n\tResults []*Result `json:\"results\"`\n}\n\n// NewStatus creates a new Status for a given Suite\nfunc NewStatus(s *Suite) *Status {\n\treturn &Status{\n\t\tName:    s.Name,\n\t\tGroup:   s.Group,\n\t\tKey:     s.Key(),\n\t\tResults: []*Result{},\n\t}\n}"
  },
  {
    "path": "config/suite/suite_test.go",
    "content": "package suite\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/gontext\"\n)\n\nfunc TestSuite_ValidateAndSetDefaults(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tsuite   *Suite\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"valid-suite\",\n\t\t\tsuite: &Suite{\n\t\t\t\tName: \"test-suite\",\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"endpoint1\",\n\t\t\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\t\t\tendpoint.Condition(\"[STATUS] == 200\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"suite-without-name\",\n\t\t\tsuite: &Suite{\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"endpoint1\",\n\t\t\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\t\t\tendpoint.Condition(\"[STATUS] == 200\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"suite-without-endpoints\",\n\t\t\tsuite: &Suite{\n\t\t\t\tName:      \"test-suite\",\n\t\t\t\tEndpoints: []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"suite-with-duplicate-endpoint-names\",\n\t\t\tsuite: &Suite{\n\t\t\t\tName: \"test-suite\",\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"duplicate\",\n\t\t\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\t\t\tendpoint.Condition(\"[STATUS] == 200\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"duplicate\",\n\t\t\t\t\t\tURL:  \"https://example.com\",\n\t\t\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\t\t\tendpoint.Condition(\"[STATUS] == 200\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.suite.ValidateAndSetDefaults()\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"Suite.ValidateAndSetDefaults() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t\t// Check defaults were set\n\t\t\tif err == nil {\n\t\t\t\tif tt.suite.Interval == 0 {\n\t\t\t\t\tt.Errorf(\"Expected Interval to be set to default, got 0\")\n\t\t\t\t}\n\t\t\t\tif tt.suite.Timeout == 0 {\n\t\t\t\t\tt.Errorf(\"Expected Timeout to be set to default, got 0\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSuite_IsEnabled(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tenabled *bool\n\t\twant    bool\n\t}{\n\t\t{\n\t\t\tname:    \"nil-defaults-to-true\",\n\t\t\tenabled: nil,\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname:    \"explicitly-enabled\",\n\t\t\tenabled: boolPtr(true),\n\t\t\twant:    true,\n\t\t},\n\t\t{\n\t\t\tname:    \"explicitly-disabled\",\n\t\t\tenabled: boolPtr(false),\n\t\t\twant:    false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ts := &Suite{Enabled: tt.enabled}\n\t\t\tif got := s.IsEnabled(); got != tt.want {\n\t\t\t\tt.Errorf(\"Suite.IsEnabled() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSuite_Key(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tsuite *Suite\n\t\twant  string\n\t}{\n\t\t{\n\t\t\tname: \"with-group\",\n\t\t\tsuite: &Suite{\n\t\t\t\tName:  \"test-suite\",\n\t\t\t\tGroup: \"test-group\",\n\t\t\t},\n\t\t\twant: \"test-group_test-suite\",\n\t\t},\n\t\t{\n\t\t\tname: \"without-group\",\n\t\t\tsuite: &Suite{\n\t\t\t\tName:  \"test-suite\",\n\t\t\t\tGroup: \"\",\n\t\t\t},\n\t\t\twant: \"_test-suite\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := tt.suite.Key(); got != tt.want {\n\t\t\t\tt.Errorf(\"Suite.Key() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSuite_DefaultValues(t *testing.T) {\n\ts := &Suite{\n\t\tName: \"test\",\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName: \"endpoint1\",\n\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\tendpoint.Condition(\"[STATUS] == 200\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\terr := s.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif s.Interval != DefaultInterval {\n\t\tt.Errorf(\"Expected Interval to be %v, got %v\", DefaultInterval, s.Interval)\n\t}\n\tif s.Timeout != DefaultTimeout {\n\t\tt.Errorf(\"Expected Timeout to be %v, got %v\", DefaultTimeout, s.Timeout)\n\t}\n\tif s.InitialContext == nil {\n\t\tt.Error(\"Expected InitialContext to be initialized, got nil\")\n\t}\n}\n\n// Helper function to create bool pointers\nfunc boolPtr(b bool) *bool {\n\treturn &b\n}\n\nfunc TestStoreResultValues(t *testing.T) {\n\tctx := gontext.New(nil)\n\t// Create a mock result\n\tresult := &endpoint.Result{\n\t\tHTTPStatus: 200,\n\t\tIP:         \"192.168.1.1\",\n\t\tDuration:   100 * time.Millisecond,\n\t\tBody:       []byte(`{\"status\": \"OK\", \"value\": 42}`),\n\t\tConnected:  true,\n\t}\n\t// Define store mappings\n\tmappings := map[string]string{\n\t\t\"response_code\": \"[STATUS]\",\n\t\t\"server_ip\":     \"[IP]\",\n\t\t\"response_time\": \"[RESPONSE_TIME]\",\n\t\t\"status\":        \"[BODY].status\",\n\t\t\"value\":         \"[BODY].value\",\n\t\t\"connected\":     \"[CONNECTED]\",\n\t}\n\t// Store values\n\tstored, err := StoreResultValues(ctx, mappings, result)\n\tif err != nil {\n\t\tt.Fatalf(\"Unexpected error storing values: %v\", err)\n\t}\n\t// Verify stored values\n\tif stored[\"response_code\"] != int64(200) {\n\t\tt.Errorf(\"Expected response_code=200, got %v\", stored[\"response_code\"])\n\t}\n\tif stored[\"server_ip\"] != \"192.168.1.1\" {\n\t\tt.Errorf(\"Expected server_ip=192.168.1.1, got %v\", stored[\"server_ip\"])\n\t}\n\tif stored[\"status\"] != \"OK\" {\n\t\tt.Errorf(\"Expected status=OK, got %v\", stored[\"status\"])\n\t}\n\tif stored[\"value\"] != int64(42) { // Now parsed as int64 for whole numbers\n\t\tt.Errorf(\"Expected value=42, got %v\", stored[\"value\"])\n\t}\n\tif stored[\"connected\"] != true {\n\t\tt.Errorf(\"Expected connected=true, got %v\", stored[\"connected\"])\n\t}\n\t// Verify values are in context\n\tval, err := ctx.Get(\"status\")\n\tif err != nil || val != \"OK\" {\n\t\tt.Errorf(\"Expected status=OK in context, got %v, err=%v\", val, err)\n\t}\n}\n\nfunc TestStoreResultValuesWithInvalidPath(t *testing.T) {\n\tctx := gontext.New(map[string]interface{}{})\n\tresult := &endpoint.Result{\n\t\tHTTPStatus: 200,\n\t\tBody:       []byte(`{\"data\": {\"name\": \"john\"}}`),\n\t}\n\t// Define store mappings with invalid paths\n\tmappings := map[string]string{\n\t\t\"valid_status\":   \"[STATUS]\",\n\t\t\"invalid_token\":  \"[BODY].accessToken\",     // This path doesn't exist\n\t\t\"invalid_nested\": \"[BODY].user.id.invalid\", // This nested path doesn't exist\n\t}\n\t// Store values - should return error for invalid paths\n\tstored, err := StoreResultValues(ctx, mappings, result)\n\tif err == nil {\n\t\tt.Fatal(\"Expected error when storing invalid paths, got nil\")\n\t}\n\t// Check that the error message contains information about the invalid paths\n\tif !strings.Contains(err.Error(), \"invalid_token\") {\n\t\tt.Errorf(\"Error should mention invalid_token, got: %v\", err)\n\t}\n\tif !strings.Contains(err.Error(), \"invalid path\") {\n\t\tt.Errorf(\"Error should mention 'invalid path', got: %v\", err)\n\t}\n\t// Verify that valid values were still stored\n\tif stored[\"valid_status\"] != int64(200) {\n\t\tt.Errorf(\"Expected valid_status=200, got %v\", stored[\"valid_status\"])\n\t}\n\t// Verify that invalid values show error messages in stored map\n\tif !strings.Contains(stored[\"invalid_token\"].(string), \"ERROR\") {\n\t\tt.Errorf(\"Expected invalid_token to contain ERROR, got %v\", stored[\"invalid_token\"])\n\t}\n\t// Verify that invalid values are NOT in context\n\t_, err = ctx.Get(\"invalid_token\")\n\tif err == nil {\n\t\tt.Error(\"Invalid token should not be stored in context\")\n\t}\n\t// Verify that valid value IS in context\n\tval, err := ctx.Get(\"valid_status\")\n\tif err != nil || val != int64(200) {\n\t\tt.Errorf(\"Expected valid_status=200 in context, got %v, err=%v\", val, err)\n\t}\n}\n\nfunc TestSuite_ExecuteWithAlwaysRunEndpoints(t *testing.T) {\n\tsuite := &Suite{\n\t\tName: \"test-suite\",\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName: \"create-resource\",\n\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\tendpoint.Condition(\"[STATUS] == 200\"),\n\t\t\t\t},\n\t\t\t\tStore: map[string]string{\n\t\t\t\t\t\"created_id\": \"[BODY]\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"failing-endpoint\",\n\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\tendpoint.Condition(\"[STATUS] != 200\"), // This will fail\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"cleanup-resource\",\n\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\tendpoint.Condition(\"[STATUS] == 200\"),\n\t\t\t\t},\n\t\t\t\tAlwaysRun: true,\n\t\t\t},\n\t\t},\n\t}\n\tif err := suite.ValidateAndSetDefaults(); err != nil {\n\t\tt.Fatalf(\"suite validation failed: %v\", err)\n\t}\n\tresult := suite.Execute()\n\tif result.Success {\n\t\tt.Error(\"expected suite to fail due to middle endpoint failure\")\n\t}\n\tif len(result.EndpointResults) != 3 {\n\t\tt.Errorf(\"expected 3 endpoint results, got %d\", len(result.EndpointResults))\n\t}\n\tif result.EndpointResults[0].Name != \"create-resource\" {\n\t\tt.Errorf(\"expected first endpoint to be 'create-resource', got '%s'\", result.EndpointResults[0].Name)\n\t}\n\tif result.EndpointResults[1].Name != \"failing-endpoint\" {\n\t\tt.Errorf(\"expected second endpoint to be 'failing-endpoint', got '%s'\", result.EndpointResults[1].Name)\n\t}\n\tif result.EndpointResults[1].Success {\n\t\tt.Error(\"expected failing-endpoint to fail\")\n\t}\n\tif result.EndpointResults[2].Name != \"cleanup-resource\" {\n\t\tt.Errorf(\"expected third endpoint to be 'cleanup-resource', got '%s'\", result.EndpointResults[2].Name)\n\t}\n\tif !result.EndpointResults[2].Success {\n\t\tt.Error(\"expected cleanup endpoint to succeed\")\n\t}\n}\n\nfunc TestSuite_ExecuteWithoutAlwaysRunEndpoints(t *testing.T) {\n\tsuite := &Suite{\n\t\tName: \"test-suite\",\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName: \"create-resource\",\n\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\tendpoint.Condition(\"[STATUS] == 200\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"failing-endpoint\",\n\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\tendpoint.Condition(\"[STATUS] != 200\"), // This will fail\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"skipped-endpoint\",\n\t\t\t\tURL:  \"https://example.org\",\n\t\t\t\tConditions: []endpoint.Condition{\n\t\t\t\t\tendpoint.Condition(\"[STATUS] == 200\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tif err := suite.ValidateAndSetDefaults(); err != nil {\n\t\tt.Fatalf(\"suite validation failed: %v\", err)\n\t}\n\tresult := suite.Execute()\n\tif result.Success {\n\t\tt.Error(\"expected suite to fail due to middle endpoint failure\")\n\t}\n\tif len(result.EndpointResults) != 2 {\n\t\tt.Errorf(\"expected 2 endpoint results (execution should stop after failure), got %d\", len(result.EndpointResults))\n\t}\n\tif result.EndpointResults[0].Name != \"create-resource\" {\n\t\tt.Errorf(\"expected first endpoint to be 'create-resource', got '%s'\", result.EndpointResults[0].Name)\n\t}\n\tif result.EndpointResults[1].Name != \"failing-endpoint\" {\n\t\tt.Errorf(\"expected second endpoint to be 'failing-endpoint', got '%s'\", result.EndpointResults[1].Name)\n\t}\n}\n\nfunc TestResult_AddError(t *testing.T) {\n\tresult := &Result{\n\t\tName:      \"test-suite\",\n\t\tTimestamp: time.Now(),\n\t}\n\tif len(result.Errors) != 0 {\n\t\tt.Errorf(\"Expected 0 errors initially, got %d\", len(result.Errors))\n\t}\n\tresult.AddError(\"first error\")\n\tif len(result.Errors) != 1 {\n\t\tt.Errorf(\"Expected 1 error after AddError, got %d\", len(result.Errors))\n\t}\n\tif result.Errors[0] != \"first error\" {\n\t\tt.Errorf(\"Expected 'first error', got '%s'\", result.Errors[0])\n\t}\n\tresult.AddError(\"second error\")\n\tif len(result.Errors) != 2 {\n\t\tt.Errorf(\"Expected 2 errors after second AddError, got %d\", len(result.Errors))\n\t}\n\tif result.Errors[1] != \"second error\" {\n\t\tt.Errorf(\"Expected 'second error', got '%s'\", result.Errors[1])\n\t}\n}\n\nfunc TestResult_CalculateSuccess(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tendpointResults []*endpoint.Result\n\t\terrors          []string\n\t\texpectedSuccess bool\n\t}{\n\t\t{\n\t\t\tname:            \"no-endpoints-no-errors\",\n\t\t\tendpointResults: []*endpoint.Result{},\n\t\t\terrors:          []string{},\n\t\t\texpectedSuccess: true,\n\t\t},\n\t\t{\n\t\t\tname: \"all-endpoints-successful-no-errors\",\n\t\t\tendpointResults: []*endpoint.Result{\n\t\t\t\t{Success: true},\n\t\t\t\t{Success: true},\n\t\t\t},\n\t\t\terrors:          []string{},\n\t\t\texpectedSuccess: true,\n\t\t},\n\t\t{\n\t\t\tname: \"second-endpoint-failed-no-errors\",\n\t\t\tendpointResults: []*endpoint.Result{\n\t\t\t\t{Success: true},\n\t\t\t\t{Success: false},\n\t\t\t},\n\t\t\terrors:          []string{},\n\t\t\texpectedSuccess: false,\n\t\t},\n\t\t{\n\t\t\tname: \"first-endpoint-failed-no-errors\",\n\t\t\tendpointResults: []*endpoint.Result{\n\t\t\t\t{Success: false},\n\t\t\t\t{Success: true},\n\t\t\t},\n\t\t\terrors:          []string{},\n\t\t\texpectedSuccess: false,\n\t\t},\n\t\t{\n\t\t\tname: \"all-endpoints-successful-with-errors\",\n\t\t\tendpointResults: []*endpoint.Result{\n\t\t\t\t{Success: true},\n\t\t\t\t{Success: true},\n\t\t\t},\n\t\t\terrors:          []string{\"suite level error\"},\n\t\t\texpectedSuccess: false,\n\t\t},\n\t\t{\n\t\t\tname: \"endpoint-failed-and-errors\",\n\t\t\tendpointResults: []*endpoint.Result{\n\t\t\t\t{Success: true},\n\t\t\t\t{Success: false},\n\t\t\t},\n\t\t\terrors:          []string{\"suite level error\"},\n\t\t\texpectedSuccess: false,\n\t\t},\n\t\t{\n\t\t\tname:            \"no-endpoints-with-errors\",\n\t\t\tendpointResults: []*endpoint.Result{},\n\t\t\terrors:          []string{\"configuration error\"},\n\t\t\texpectedSuccess: false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := &Result{\n\t\t\t\tName:            \"test-suite\",\n\t\t\t\tTimestamp:       time.Now(),\n\t\t\t\tEndpointResults: tt.endpointResults,\n\t\t\t\tErrors:          tt.errors,\n\t\t\t}\n\t\t\tresult.CalculateSuccess()\n\t\t\tif result.Success != tt.expectedSuccess {\n\t\t\t\tt.Errorf(\"Expected success=%v, got %v\", tt.expectedSuccess, result.Success)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "config/tunneling/sshtunnel/sshtunnel.go",
    "content": "package sshtunnel\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"golang.org/x/crypto/ssh\"\n)\n\n// Config represents the configuration for an SSH tunnel\ntype Config struct {\n\tType       string `yaml:\"type\"`\n\tHost       string `yaml:\"host\"`\n\tPort       int    `yaml:\"port,omitempty\"`\n\tUsername   string `yaml:\"username\"`\n\tPrivateKey string `yaml:\"private-key,omitempty\"`\n\tPassword   string `yaml:\"password,omitempty\"`\n}\n\n// ValidateAndSetDefaults validates the SSH tunnel configuration and sets defaults\nfunc (c *Config) ValidateAndSetDefaults() error {\n\tif c.Type != \"SSH\" {\n\t\treturn fmt.Errorf(\"unsupported tunnel type: %s\", c.Type)\n\t}\n\tif c.Host == \"\" {\n\t\treturn fmt.Errorf(\"host is required\")\n\t}\n\tif c.Username == \"\" {\n\t\treturn fmt.Errorf(\"username is required\")\n\t}\n\tif c.PrivateKey == \"\" && c.Password == \"\" {\n\t\treturn fmt.Errorf(\"either private-key or password is required\")\n\t}\n\tif c.Port == 0 {\n\t\tc.Port = 22\n\t}\n\treturn nil\n}\n\n// SSHTunnel represents an SSH tunnel connection\ntype SSHTunnel struct {\n\tconfig *Config\n\tmu     sync.RWMutex\n\tclient *ssh.Client\n\n\t// Cached authentication methods to avoid reparsing private keys\n\tauthMethods []ssh.AuthMethod\n}\n\n// New creates a new SSH tunnel with the given configuration\nfunc New(config *Config) *SSHTunnel {\n\ttunnel := &SSHTunnel{\n\t\tconfig: config,\n\t}\n\t// Parse authentication methods once during initialization to avoid\n\t// expensive cryptographic operations on every connection attempt\n\tif config.PrivateKey != \"\" {\n\t\tif signer, err := ssh.ParsePrivateKey([]byte(config.PrivateKey)); err == nil {\n\t\t\ttunnel.authMethods = []ssh.AuthMethod{ssh.PublicKeys(signer)}\n\t\t}\n\t\t// Note: We don't return error here to maintain backward compatibility.\n\t\t// Invalid keys will be caught during first connection attempt.\n\t} else if config.Password != \"\" {\n\t\ttunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}\n\t}\n\treturn tunnel\n}\n\n// Connect establishes the SSH connection\nfunc (t *SSHTunnel) Connect() error {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\treturn t.connectUnsafe()\n}\n\n// connectUnsafe establishes the SSH connection without acquiring locks\n// Must be called with t.mu.Lock() already held\nfunc (t *SSHTunnel) connectUnsafe() error {\n\t// Use cached authentication methods to avoid expensive crypto operations\n\tif len(t.authMethods) == 0 {\n\t\treturn fmt.Errorf(\"no authentication method available\")\n\t}\n\tconfig := &ssh.ClientConfig{\n\t\tUser:            t.config.Username,\n\t\tTimeout:         30 * time.Second,\n\t\tHostKeyCallback: ssh.InsecureIgnoreHostKey(), // Skip host key verification\n\t\tAuth:            t.authMethods,               // Use pre-parsed authentication\n\t}\n\t// Connect to SSH server\n\taddr := fmt.Sprintf(\"%s:%d\", t.config.Host, t.config.Port)\n\tclient, err := ssh.Dial(\"tcp\", addr, config)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"SSH connection failed: %w\", err)\n\t}\n\tt.client = client\n\treturn nil\n}\n\n// Close closes the SSH connection\nfunc (t *SSHTunnel) Close() error {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tif t.client != nil {\n\t\terr := t.client.Close()\n\t\tt.client = nil\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// Dial creates a connection through the SSH tunnel\nfunc (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {\n\tt.mu.RLock()\n\tclient := t.client\n\tt.mu.RUnlock()\n\t// Ensure we have an SSH connection\n\tif client == nil {\n\t\t// Use write lock to prevent race condition during connection\n\t\tt.mu.Lock()\n\t\t// Double-check client after acquiring lock\n\t\tif t.client == nil {\n\t\t\tif err := t.connectUnsafe(); err != nil {\n\t\t\t\tt.mu.Unlock()\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t\tclient = t.client\n\t\tt.mu.Unlock()\n\t}\n\t// Attempt dial with exponential backoff retry\n\tconst maxRetries = 3\n\tconst baseDelay = 500 * time.Millisecond\n\tvar lastErr error\n\tfor attempt := range maxRetries {\n\t\tif attempt > 0 {\n\t\t\t// Exponential backoff: 500ms, 1s, 2s\n\t\t\tdelay := baseDelay << (attempt - 1)\n\t\t\ttime.Sleep(delay)\n\t\t\t// Close stale connection and reconnect\n\t\t\tt.mu.Lock()\n\t\t\tif t.client != nil {\n\t\t\t\t_ = t.client.Close()\n\t\t\t\tt.client = nil\n\t\t\t}\n\t\t\tif err := t.connectUnsafe(); err != nil {\n\t\t\t\tt.mu.Unlock()\n\t\t\t\tlastErr = fmt.Errorf(\"reconnect attempt %d failed: %w\", attempt, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tclient = t.client\n\t\t\tt.mu.Unlock()\n\t\t}\n\t\tconn, err := client.Dial(network, addr)\n\t\tif err == nil {\n\t\t\treturn conn, nil\n\t\t}\n\t\tlastErr = err\n\t}\n\treturn nil, fmt.Errorf(\"SSH tunnel dial failed after %d attempts: %w\", maxRetries, lastErr)\n}\n"
  },
  {
    "path": "config/tunneling/sshtunnel/sshtunnel_test.go",
    "content": "package sshtunnel\n\nimport (\n\t\"testing\"\n)\n\nfunc TestConfig_ValidateAndSetDefaults(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tconfig  *Config\n\t\twantErr bool\n\t\terrMsg  string\n\t}{\n\t\t{\n\t\t\tname: \"valid SSH config with private key\",\n\t\t\tconfig: &Config{\n\t\t\t\tType:       \"SSH\",\n\t\t\t\tHost:       \"example.com\",\n\t\t\t\tUsername:   \"test\",\n\t\t\t\tPrivateKey: \"-----BEGIN RSA PRIVATE KEY-----\\ntest\\n-----END RSA PRIVATE KEY-----\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"valid SSH config with password\",\n\t\t\tconfig: &Config{\n\t\t\t\tType:     \"SSH\",\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tUsername: \"test\",\n\t\t\t\tPassword: \"secret\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"valid SSH config with custom port\",\n\t\t\tconfig: &Config{\n\t\t\t\tType:     \"SSH\",\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     2222,\n\t\t\t\tUsername: \"test\",\n\t\t\t\tPassword: \"secret\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"sets default port 22\",\n\t\t\tconfig: &Config{\n\t\t\t\tType:     \"SSH\",\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tUsername: \"test\",\n\t\t\t\tPassword: \"secret\",\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid type\",\n\t\t\tconfig: &Config{\n\t\t\t\tType:     \"INVALID\",\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tUsername: \"test\",\n\t\t\t\tPassword: \"secret\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"unsupported tunnel type: INVALID\",\n\t\t},\n\t\t{\n\t\t\tname: \"missing host\",\n\t\t\tconfig: &Config{\n\t\t\t\tType:     \"SSH\",\n\t\t\t\tUsername: \"test\",\n\t\t\t\tPassword: \"secret\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"host is required\",\n\t\t},\n\t\t{\n\t\t\tname: \"missing username\",\n\t\t\tconfig: &Config{\n\t\t\t\tType:     \"SSH\",\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPassword: \"secret\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"username is required\",\n\t\t},\n\t\t{\n\t\t\tname: \"missing authentication\",\n\t\t\tconfig: &Config{\n\t\t\t\tType:     \"SSH\",\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tUsername: \"test\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"either private-key or password is required\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\toriginalPort := tt.config.Port\n\t\t\terr := tt.config.ValidateAndSetDefaults()\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"ValidateAndSetDefaults() expected error but got none\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif err.Error() != tt.errMsg {\n\t\t\t\t\tt.Errorf(\"ValidateAndSetDefaults() error = %v, want %v\", err.Error(), tt.errMsg)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ValidateAndSetDefaults() unexpected error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Check that default port is set\n\t\t\tif originalPort == 0 && tt.config.Port != 22 {\n\t\t\t\tt.Errorf(\"ValidateAndSetDefaults() expected default port 22, got %d\", tt.config.Port)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNew(t *testing.T) {\n\tconfig := &Config{\n\t\tType:     \"SSH\",\n\t\tHost:     \"example.com\",\n\t\tUsername: \"test\",\n\t\tPassword: \"secret\",\n\t}\n\ttunnel := New(config)\n\tif tunnel == nil {\n\t\tt.Error(\"New() returned nil\")\n\t\treturn\n\t}\n\tif tunnel.config != config {\n\t\tt.Error(\"New() did not set config correctly\")\n\t}\n}\n\nfunc TestSSHTunnel_Close(t *testing.T) {\n\tconfig := &Config{\n\t\tType:     \"SSH\",\n\t\tHost:     \"example.com\",\n\t\tUsername: \"test\",\n\t\tPassword: \"secret\",\n\t}\n\ttunnel := New(config)\n\t// Test closing when no client is set\n\terr := tunnel.Close()\n\tif err != nil {\n\t\tt.Errorf(\"Close() with no client returned error: %v\", err)\n\t}\n\t// Test closing multiple times\n\terr = tunnel.Close()\n\tif err != nil {\n\t\tt.Errorf(\"Close() called twice returned error: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "config/tunneling/tunneling.go",
    "content": "package tunneling\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel\"\n)\n\n// Config represents the tunneling configuration\ntype Config struct {\n\t// Tunnels is a map of SSH tunnel configurations in which the key is the name of the tunnel\n\tTunnels map[string]*sshtunnel.Config `yaml:\",inline\"`\n\n\tmu          sync.RWMutex                    `yaml:\"-\"`\n\tconnections map[string]*sshtunnel.SSHTunnel `yaml:\"-\"`\n}\n\n// ValidateAndSetDefaults validates the tunneling configuration and sets defaults\nfunc (tc *Config) ValidateAndSetDefaults() error {\n\tif tc.connections == nil {\n\t\ttc.connections = make(map[string]*sshtunnel.SSHTunnel)\n\t}\n\tfor name, config := range tc.Tunnels {\n\t\tif err := config.ValidateAndSetDefaults(); err != nil {\n\t\t\treturn fmt.Errorf(\"tunnel '%s': %w\", name, err)\n\t\t}\n\t}\n\treturn nil\n}\n\n// GetTunnel returns the SSH tunnel for the given name, creating it if necessary\nfunc (tc *Config) GetTunnel(name string) (*sshtunnel.SSHTunnel, error) {\n\tif name == \"\" {\n\t\treturn nil, fmt.Errorf(\"tunnel name cannot be empty\")\n\t}\n\ttc.mu.Lock()\n\tdefer tc.mu.Unlock()\n\t// Check if tunnel already exists\n\tif tunnel, exists := tc.connections[name]; exists {\n\t\treturn tunnel, nil\n\t}\n\t// Get config for this tunnel\n\tconfig, exists := tc.Tunnels[name]\n\tif !exists {\n\t\treturn nil, fmt.Errorf(\"tunnel '%s' not found in configuration\", name)\n\t}\n\t// Create and store new tunnel\n\ttunnel := sshtunnel.New(config)\n\ttc.connections[name] = tunnel\n\treturn tunnel, nil\n}\n\n// Close closes all SSH tunnel connections\nfunc (tc *Config) Close() error {\n\ttc.mu.Lock()\n\tdefer tc.mu.Unlock()\n\tvar errors []string\n\tfor name, tunnel := range tc.connections {\n\t\tif err := tunnel.Close(); err != nil {\n\t\t\terrors = append(errors, fmt.Sprintf(\"tunnel '%s': %v\", name, err))\n\t\t}\n\t\tdelete(tc.connections, name)\n\t}\n\tif len(errors) > 0 {\n\t\treturn fmt.Errorf(\"failed to close tunnels: %s\", strings.Join(errors, \", \"))\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "config/tunneling/tunneling_test.go",
    "content": "package tunneling\n\nimport (\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel\"\n)\n\nfunc TestConfig_ValidateAndSetDefaults(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tconfig  *Config\n\t\twantErr bool\n\t\terrMsg  string\n\t}{\n\t\t{\n\t\t\tname: \"valid config with SSH tunnel\",\n\t\t\tconfig: &Config{\n\t\t\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\t\t\"test\": {\n\t\t\t\t\t\tType:     \"SSH\",\n\t\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\t\tUsername: \"test\",\n\t\t\t\t\t\tPassword: \"secret\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple valid tunnels\",\n\t\t\tconfig: &Config{\n\t\t\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\t\t\"tunnel1\": {\n\t\t\t\t\t\tType:       \"SSH\",\n\t\t\t\t\t\tHost:       \"host1.com\",\n\t\t\t\t\t\tUsername:   \"user1\",\n\t\t\t\t\t\tPrivateKey: \"key1\",\n\t\t\t\t\t},\n\t\t\t\t\t\"tunnel2\": {\n\t\t\t\t\t\tType:     \"SSH\",\n\t\t\t\t\t\tHost:     \"host2.com\",\n\t\t\t\t\t\tUsername: \"user2\",\n\t\t\t\t\t\tPassword: \"pass2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid tunnel config\",\n\t\t\tconfig: &Config{\n\t\t\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\t\t\"invalid\": {\n\t\t\t\t\t\tType:     \"INVALID\",\n\t\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\t\tUsername: \"test\",\n\t\t\t\t\t\tPassword: \"secret\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"tunnel 'invalid': unsupported tunnel type: INVALID\",\n\t\t},\n\t\t{\n\t\t\tname: \"missing host in tunnel\",\n\t\t\tconfig: &Config{\n\t\t\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\t\t\"nohost\": {\n\t\t\t\t\t\tType:     \"SSH\",\n\t\t\t\t\t\tUsername: \"test\",\n\t\t\t\t\t\tPassword: \"secret\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"tunnel 'nohost': host is required\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\terr := tt.config.ValidateAndSetDefaults()\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"ValidateAndSetDefaults() expected error but got none\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif err.Error() != tt.errMsg {\n\t\t\t\t\tt.Errorf(\"ValidateAndSetDefaults() error = %v, want %v\", err.Error(), tt.errMsg)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"ValidateAndSetDefaults() unexpected error = %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Check that connections map is initialized\n\t\t\tif tt.config != nil && tt.config.connections == nil {\n\t\t\t\tt.Error(\"ValidateAndSetDefaults() did not initialize connections map\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_GetTunnel(t *testing.T) {\n\tconfig := &Config{\n\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\"test\": {\n\t\t\t\tType:     \"SSH\",\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tUsername: \"test\",\n\t\t\t\tPassword: \"secret\",\n\t\t\t},\n\t\t},\n\t}\n\terr := config.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatalf(\"ValidateAndSetDefaults() failed: %v\", err)\n\t}\n\t// Test getting existing tunnel\n\ttunnel1, err := config.GetTunnel(\"test\")\n\tif err != nil {\n\t\tt.Errorf(\"GetTunnel() error = %v\", err)\n\t\treturn\n\t}\n\tif tunnel1 == nil {\n\t\tt.Error(\"GetTunnel() returned nil tunnel\")\n\t\treturn\n\t}\n\t// Test getting same tunnel again (should return same instance)\n\ttunnel2, err := config.GetTunnel(\"test\")\n\tif err != nil {\n\t\tt.Errorf(\"GetTunnel() second call error = %v\", err)\n\t\treturn\n\t}\n\tif tunnel1 != tunnel2 {\n\t\tt.Error(\"GetTunnel() should return same instance for same tunnel name\")\n\t}\n\t// Test getting non-existent tunnel\n\t_, err = config.GetTunnel(\"nonexistent\")\n\tif err == nil {\n\t\tt.Error(\"GetTunnel() expected error for non-existent tunnel\")\n\t\treturn\n\t}\n\texpectedErr := \"tunnel 'nonexistent' not found in configuration\"\n\tif err.Error() != expectedErr {\n\t\tt.Errorf(\"GetTunnel() error = %v, want %v\", err.Error(), expectedErr)\n\t}\n}\n\nfunc TestConfig_Close(t *testing.T) {\n\t// Test closing config with tunnels\n\tconfig := &Config{\n\t\tTunnels: map[string]*sshtunnel.Config{\n\t\t\t\"test1\": {\n\t\t\t\tType:     \"SSH\",\n\t\t\t\tHost:     \"example1.com\",\n\t\t\t\tUsername: \"test\",\n\t\t\t\tPassword: \"secret\",\n\t\t\t},\n\t\t\t\"test2\": {\n\t\t\t\tType:     \"SSH\",\n\t\t\t\tHost:     \"example2.com\",\n\t\t\t\tUsername: \"test\",\n\t\t\t\tPassword: \"secret\",\n\t\t\t},\n\t\t},\n\t}\n\terr := config.ValidateAndSetDefaults()\n\tif err != nil {\n\t\tt.Fatalf(\"ValidateAndSetDefaults() failed: %v\", err)\n\t}\n\t// Create some tunnels\n\t_, err = config.GetTunnel(\"test1\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetTunnel() failed: %v\", err)\n\t}\n\t_, err = config.GetTunnel(\"test2\")\n\tif err != nil {\n\t\tt.Fatalf(\"GetTunnel() failed: %v\", err)\n\t}\n\t// Test closing\n\terr = config.Close()\n\tif err != nil {\n\t\tt.Errorf(\"Close() returned error: %v\", err)\n\t}\n\t// Verify connections map is empty\n\tif len(config.connections) != 0 {\n\t\tt.Errorf(\"Close() did not clear connections map, got %d connections\", len(config.connections))\n\t}\n}\n"
  },
  {
    "path": "config/ui/ui.go",
    "content": "package ui\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"html/template\"\n\n\t\"github.com/TwiN/gatus/v5/storage\"\n\tstatic \"github.com/TwiN/gatus/v5/web\"\n)\n\nconst (\n\tdefaultTitle                = \"Health Dashboard | Gatus\"\n\tdefaultDescription          = \"Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue\"\n\tdefaultHeader               = \"Gatus\"\n\tdefaultDashboardHeading     = \"Health Dashboard\"\n\tdefaultDashboardSubheading  = \"Monitor the health of your endpoints in real-time\"\n\tdefaultLogo                 = \"\"\n\tdefaultLink                 = \"\"\n\tdefaultFavicon              = \"/favicon.ico\"\n\tdefaultFavicon16            = \"/favicon-16x16.png\"\n\tdefaultFavicon32            = \"/favicon-32x32.png\"\n\tdefaultCustomCSS            = \"\"\n\tdefaultSortBy               = \"name\"\n\tdefaultFilterBy             = \"none\"\n)\n\nvar (\n\tdefaultDarkMode = true\n\n\tErrButtonValidationFailed = errors.New(\"invalid button configuration: missing required name or link\")\n\tErrInvalidDefaultSortBy   = errors.New(\"invalid default-sort-by value: must be 'name', 'group', or 'health'\")\n\tErrInvalidDefaultFilterBy = errors.New(\"invalid default-filter-by value: must be 'none', 'failing', or 'unstable'\")\n)\n\n// Config is the configuration for the UI of Gatus\ntype Config struct {\n\tTitle                   string   `yaml:\"title,omitempty\"`                  // Title of the page\n\tDescription             string   `yaml:\"description,omitempty\"`            // Meta description of the page\n\tDashboardHeading        string   `yaml:\"dashboard-heading,omitempty\"`      // Dashboard Title between header and endpoints\n\tDashboardSubheading     string   `yaml:\"dashboard-subheading,omitempty\"`   // Dashboard Description between header and endpoints\n\tHeader                  string   `yaml:\"header,omitempty\"`                 // Header is the text at the top of the page\n\tLogo                    string   `yaml:\"logo,omitempty\"`                   // Logo to display on the page\n\tLink                    string   `yaml:\"link,omitempty\"`                   // Link to open when clicking on the logo\n\tFavicon                 Favicon  `yaml:\"favicon,omitempty\"`                // Favourite icon to display in web browser tab or address bar\n\tButtons                 []Button `yaml:\"buttons,omitempty\"`                // Buttons to display below the header\n\tCustomCSS               string   `yaml:\"custom-css,omitempty\"`             // Custom CSS to include in the page\n\tDarkMode                *bool    `yaml:\"dark-mode,omitempty\"`              // DarkMode is a flag to enable dark mode by default\n\tDefaultSortBy           string   `yaml:\"default-sort-by,omitempty\"`        // DefaultSortBy is the default sort option ('name', 'group', 'health')\n\tDefaultFilterBy         string   `yaml:\"default-filter-by,omitempty\"`      // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')\n\t//////////////////////////////////////////////\n\t// Non-configurable - used for UI rendering //\n\t//////////////////////////////////////////////\n\tMaximumNumberOfResults int `yaml:\"-\"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config\n}\n\nfunc (cfg *Config) IsDarkMode() bool {\n\tif cfg.DarkMode != nil {\n\t\treturn *cfg.DarkMode\n\t}\n\treturn defaultDarkMode\n}\n\n// Button is the configuration for a button on the UI\ntype Button struct {\n\tName string `yaml:\"name,omitempty\"` // Name is the text to display on the button\n\tLink string `yaml:\"link,omitempty\"` // Link to open when the button is clicked.\n}\n\n// Validate validates the button configuration\nfunc (btn *Button) Validate() error {\n\tif len(btn.Name) == 0 || len(btn.Link) == 0 {\n\t\treturn ErrButtonValidationFailed\n\t}\n\treturn nil\n}\n\ntype Favicon struct {\n\tDefault   string `yaml:\"default,omitempty\"`   // URL or path to default favourite icon.\n\tSize16x16 string `yaml:\"size16x16,omitempty\"` // URL or path to favourite icon for 16x16 size.\n\tSize32x32 string `yaml:\"size32x32,omitempty\"` // URL or path to favourite icon for 32x32 size.\n}\n\n// GetDefaultConfig returns a Config struct with the default values\nfunc GetDefaultConfig() *Config {\n\treturn &Config{\n\t\tTitle:                  defaultTitle,\n\t\tDescription:            defaultDescription,\n\t\tDashboardHeading:       defaultDashboardHeading,\n\t\tDashboardSubheading:    defaultDashboardSubheading,\n\t\tHeader:                 defaultHeader,\n\t\tLogo:                   defaultLogo,\n\t\tLink:                   defaultLink,\n\t\tCustomCSS:              defaultCustomCSS,\n\t\tDarkMode:               &defaultDarkMode,\n\t\tDefaultSortBy:          defaultSortBy,\n\t\tDefaultFilterBy:        defaultFilterBy,\n\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\tFavicon: Favicon{\n\t\t\tDefault:   defaultFavicon,\n\t\t\tSize16x16: defaultFavicon16,\n\t\t\tSize32x32: defaultFavicon32,\n\t\t},\n\t}\n}\n\n// ValidateAndSetDefaults validates the UI configuration and sets the default values if necessary.\nfunc (cfg *Config) ValidateAndSetDefaults() error {\n\tif len(cfg.Title) == 0 {\n\t\tcfg.Title = defaultTitle\n\t}\n\tif len(cfg.Description) == 0 {\n\t\tcfg.Description = defaultDescription\n\t}\n\tif len(cfg.DashboardHeading) == 0 {\n\t\tcfg.DashboardHeading = defaultDashboardHeading\n\t}\n\tif len(cfg.DashboardSubheading) == 0 {\n\t\tcfg.DashboardSubheading = defaultDashboardSubheading\n\t}\n\tif len(cfg.Header) == 0 {\n\t\tcfg.Header = defaultHeader\n\t}\n\tif len(cfg.Logo) == 0 {\n\t\tcfg.Logo = defaultLogo\n\t}\n\tif len(cfg.Link) == 0 {\n\t\tcfg.Link = defaultLink\n\t}\n\tif len(cfg.CustomCSS) == 0 {\n\t\tcfg.CustomCSS = defaultCustomCSS\n\t}\n\tif cfg.DarkMode == nil {\n\t\tcfg.DarkMode = &defaultDarkMode\n\t}\n\tif len(cfg.DefaultSortBy) == 0 {\n\t\tcfg.DefaultSortBy = defaultSortBy\n\t} else if cfg.DefaultSortBy != \"name\" && cfg.DefaultSortBy != \"group\" && cfg.DefaultSortBy != \"health\" {\n\t\treturn ErrInvalidDefaultSortBy\n\t}\n\tif len(cfg.DefaultFilterBy) == 0 {\n\t\tcfg.DefaultFilterBy = defaultFilterBy\n\t} else if cfg.DefaultFilterBy != \"none\" && cfg.DefaultFilterBy != \"failing\" && cfg.DefaultFilterBy != \"unstable\" {\n\t\treturn ErrInvalidDefaultFilterBy\n\t}\n\tif len(cfg.Favicon.Default) == 0 {\n\t\tcfg.Favicon.Default = defaultFavicon\n\t}\n\tif len(cfg.Favicon.Size16x16) == 0 {\n\t\tcfg.Favicon.Size16x16 = defaultFavicon16\n\t}\n\tif len(cfg.Favicon.Size32x32) == 0 {\n\t\tcfg.Favicon.Size32x32 = defaultFavicon32\n\t}\n\tfor _, btn := range cfg.Buttons {\n\t\tif err := btn.Validate(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t// Validate that the template works\n\tt, err := template.ParseFS(static.FileSystem, static.IndexPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar buffer bytes.Buffer\n\treturn t.Execute(&buffer, ViewData{UI: cfg, Theme: \"dark\"})\n}\n\ntype ViewData struct {\n\tUI    *Config\n\tTheme string\n}\n"
  },
  {
    "path": "config/ui/ui_test.go",
    "content": "package ui\n\nimport (\n\t\"errors\"\n\t\"strconv\"\n\t\"testing\"\n)\n\nfunc TestConfig_ValidateAndSetDefaults(t *testing.T) {\n\tt.Run(\"empty-config\", func(t *testing.T) {\n\t\tcfg := &Config{\n\t\t\tTitle:               \"\",\n\t\t\tDescription:         \"\",\n\t\t\tDashboardHeading:    \"\",\n\t\t\tDashboardSubheading: \"\",\n\t\t\tHeader:              \"\",\n\t\t\tLogo:                \"\",\n\t\t\tLink:                \"\",\n\t\t}\n\t\tif err := cfg.ValidateAndSetDefaults(); err != nil {\n\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t}\n\t\tif cfg.Title != defaultTitle {\n\t\t\tt.Errorf(\"expected title to be %s, got %s\", defaultTitle, cfg.Title)\n\t\t}\n\t\tif cfg.Description != defaultDescription {\n\t\t\tt.Errorf(\"expected description to be %s, got %s\", defaultDescription, cfg.Description)\n\t\t}\n\t\tif cfg.DashboardHeading != defaultDashboardHeading {\n\t\t\tt.Errorf(\"expected DashboardHeading to be %s, got %s\", defaultDashboardHeading, cfg.DashboardHeading)\n\t\t}\n\t\tif cfg.DashboardSubheading != defaultDashboardSubheading {\n\t\t\tt.Errorf(\"expected DashboardSubheading to be %s, got %s\", defaultDashboardSubheading, cfg.DashboardSubheading)\n\t\t}\n\t\tif cfg.Header != defaultHeader {\n\t\t\tt.Errorf(\"expected header to be %s, got %s\", defaultHeader, cfg.Header)\n\t\t}\n\t\tif cfg.DefaultSortBy != defaultSortBy {\n\t\t\tt.Errorf(\"expected defaultSortBy to be %s, got %s\", defaultSortBy, cfg.DefaultSortBy)\n\t\t}\n\t\tif cfg.DefaultFilterBy != defaultFilterBy {\n\t\t\tt.Errorf(\"expected defaultFilterBy to be %s, got %s\", defaultFilterBy, cfg.DefaultFilterBy)\n\t\t}\n\t\tif cfg.Favicon.Default != defaultFavicon {\n\t\t\tt.Errorf(\"expected favicon to be %s, got %s\", defaultFavicon, cfg.Favicon.Default)\n\t\t}\n\t\tif cfg.Favicon.Size16x16 != defaultFavicon16 {\n\t\t\tt.Errorf(\"expected favicon to be %s, got %s\", defaultFavicon16, cfg.Favicon.Size16x16)\n\t\t}\n\t\tif cfg.Favicon.Size32x32 != defaultFavicon32 {\n\t\t\tt.Errorf(\"expected favicon to be %s, got %s\", defaultFavicon32, cfg.Favicon.Size32x32)\n\t\t}\n\t})\n\tt.Run(\"custom-values\", func(t *testing.T) {\n\t\tcfg := &Config{\n\t\t\tTitle:               \"Custom Title\",\n\t\t\tDescription:         \"Custom Description\",\n\t\t\tDashboardHeading:    \"Production Status\",\n\t\t\tDashboardSubheading: \"Monitor all production endpoints\",\n\t\t\tHeader:              \"My Company\",\n\t\t\tLogo:                \"https://example.com/logo.png\",\n\t\t\tLink:                \"https://example.com\",\n\t\t\tDefaultSortBy:       \"health\",\n\t\t\tDefaultFilterBy:     \"failing\",\n\t\t}\n\t\tif err := cfg.ValidateAndSetDefaults(); err != nil {\n\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t}\n\t\tif cfg.Title != \"Custom Title\" {\n\t\t\tt.Errorf(\"expected title to be preserved, got %s\", cfg.Title)\n\t\t}\n\t\tif cfg.Description != \"Custom Description\" {\n\t\t\tt.Errorf(\"expected description to be preserved, got %s\", cfg.Description)\n\t\t}\n\t\tif cfg.DashboardHeading != \"Production Status\" {\n\t\t\tt.Errorf(\"expected DashboardHeading to be preserved, got %s\", cfg.DashboardHeading)\n\t\t}\n\t\tif cfg.DashboardSubheading != \"Monitor all production endpoints\" {\n\t\t\tt.Errorf(\"expected DashboardSubheading to be preserved, got %s\", cfg.DashboardSubheading)\n\t\t}\n\t\tif cfg.Header != \"My Company\" {\n\t\t\tt.Errorf(\"expected header to be preserved, got %s\", cfg.Header)\n\t\t}\n\t\tif cfg.Logo != \"https://example.com/logo.png\" {\n\t\t\tt.Errorf(\"expected logo to be preserved, got %s\", cfg.Logo)\n\t\t}\n\t\tif cfg.Link != \"https://example.com\" {\n\t\t\tt.Errorf(\"expected link to be preserved, got %s\", cfg.Link)\n\t\t}\n\t\tif cfg.DefaultSortBy != \"health\" {\n\t\t\tt.Errorf(\"expected defaultSortBy to be preserved, got %s\", cfg.DefaultSortBy)\n\t\t}\n\t\tif cfg.DefaultFilterBy != \"failing\" {\n\t\t\tt.Errorf(\"expected defaultFilterBy to be preserved, got %s\", cfg.DefaultFilterBy)\n\t\t}\n\t})\n\tt.Run(\"partial-custom-values\", func(t *testing.T) {\n\t\tcfg := &Config{\n\t\t\tTitle:               \"Custom Title\",\n\t\t\tDashboardHeading:    \"My Dashboard\",\n\t\t\tHeader:              \"\",\n\t\t\tDashboardSubheading: \"\",\n\t\t}\n\t\tif err := cfg.ValidateAndSetDefaults(); err != nil {\n\t\t\tt.Error(\"expected no error, got\", err.Error())\n\t\t}\n\t\tif cfg.Title != \"Custom Title\" {\n\t\t\tt.Errorf(\"expected custom title to be preserved, got %s\", cfg.Title)\n\t\t}\n\t\tif cfg.DashboardHeading != \"My Dashboard\" {\n\t\t\tt.Errorf(\"expected custom DashboardHeading to be preserved, got %s\", cfg.DashboardHeading)\n\t\t}\n\t\tif cfg.DashboardSubheading != defaultDashboardSubheading {\n\t\t\tt.Errorf(\"expected DashboardSubheading to use default, got %s\", cfg.DashboardSubheading)\n\t\t}\n\t\tif cfg.Header != defaultHeader {\n\t\t\tt.Errorf(\"expected header to use default, got %s\", cfg.Header)\n\t\t}\n\t\tif cfg.Description != defaultDescription {\n\t\t\tt.Errorf(\"expected description to use default, got %s\", cfg.Description)\n\t\t}\n\t})\n}\n\nfunc TestButton_Validate(t *testing.T) {\n\tscenarios := []struct {\n\t\tName, Link    string\n\t\tExpectedError error\n\t}{\n\t\t{\n\t\t\tName:          \"\",\n\t\t\tLink:          \"\",\n\t\t\tExpectedError: ErrButtonValidationFailed,\n\t\t},\n\t\t{\n\t\t\tName:          \"\",\n\t\t\tLink:          \"link\",\n\t\t\tExpectedError: ErrButtonValidationFailed,\n\t\t},\n\t\t{\n\t\t\tName:          \"name\",\n\t\t\tLink:          \"\",\n\t\t\tExpectedError: ErrButtonValidationFailed,\n\t\t},\n\t\t{\n\t\t\tName:          \"name\",\n\t\t\tLink:          \"link\",\n\t\t\tExpectedError: nil,\n\t\t},\n\t}\n\tfor i, scenario := range scenarios {\n\t\tt.Run(strconv.Itoa(i)+\"_\"+scenario.Name+\"_\"+scenario.Link, func(t *testing.T) {\n\t\t\tbutton := &Button{\n\t\t\t\tName: scenario.Name,\n\t\t\t\tLink: scenario.Link,\n\t\t\t}\n\t\t\tif err := button.Validate(); err != scenario.ExpectedError {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.ExpectedError, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetDefaultConfig(t *testing.T) {\n\tdefaultConfig := GetDefaultConfig()\n\tif defaultConfig.Title != defaultTitle {\n\t\tt.Error(\"expected GetDefaultConfig() to return defaultTitle, got\", defaultConfig.Title)\n\t}\n\tif defaultConfig.DashboardHeading != defaultDashboardHeading {\n\t\tt.Error(\"expected GetDefaultConfig() to return defaultDashboardHeading, got\", defaultConfig.DashboardHeading)\n\t}\n\tif defaultConfig.DashboardSubheading != defaultDashboardSubheading {\n\t\tt.Error(\"expected GetDefaultConfig() to return defaultDashboardSubheading, got\", defaultConfig.DashboardSubheading)\n\t}\n\tif defaultConfig.Logo != defaultLogo {\n\t\tt.Error(\"expected GetDefaultConfig() to return defaultLogo, got\", defaultConfig.Logo)\n\t}\n\tif defaultConfig.DefaultSortBy != defaultSortBy {\n\t\tt.Error(\"expected GetDefaultConfig() to return defaultSortBy, got\", defaultConfig.DefaultSortBy)\n\t}\n\tif defaultConfig.DefaultFilterBy != defaultFilterBy {\n\t\tt.Error(\"expected GetDefaultConfig() to return defaultFilterBy, got\", defaultConfig.DefaultFilterBy)\n\t}\n}\n\nfunc TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {\n\tscenarios := []struct {\n\t\tName          string\n\t\tDefaultSortBy string\n\t\tExpectedError error\n\t\tExpectedValue string\n\t}{\n\t\t{\n\t\t\tName:          \"EmptyDefaultSortBy\",\n\t\t\tDefaultSortBy: \"\",\n\t\t\tExpectedError: nil,\n\t\t\tExpectedValue: defaultSortBy,\n\t\t},\n\t\t{\n\t\t\tName:          \"ValidDefaultSortBy_name\",\n\t\t\tDefaultSortBy: \"name\",\n\t\t\tExpectedError: nil,\n\t\t\tExpectedValue: \"name\",\n\t\t},\n\t\t{\n\t\t\tName:          \"ValidDefaultSortBy_group\",\n\t\t\tDefaultSortBy: \"group\",\n\t\t\tExpectedError: nil,\n\t\t\tExpectedValue: \"group\",\n\t\t},\n\t\t{\n\t\t\tName:          \"ValidDefaultSortBy_health\",\n\t\t\tDefaultSortBy: \"health\",\n\t\t\tExpectedError: nil,\n\t\t\tExpectedValue: \"health\",\n\t\t},\n\t\t{\n\t\t\tName:          \"InvalidDefaultSortBy\",\n\t\t\tDefaultSortBy: \"invalid\",\n\t\t\tExpectedError: ErrInvalidDefaultSortBy,\n\t\t\tExpectedValue: \"invalid\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg := &Config{DefaultSortBy: scenario.DefaultSortBy}\n\t\t\terr := cfg.ValidateAndSetDefaults()\n\t\t\tif !errors.Is(err, scenario.ExpectedError) {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.ExpectedError, err)\n\t\t\t}\n\t\t\tif cfg.DefaultSortBy != scenario.ExpectedValue {\n\t\t\t\tt.Errorf(\"expected DefaultSortBy to be %s, got %s\", scenario.ExpectedValue, cfg.DefaultSortBy)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_ValidateAndSetDefaults_DefaultFilterBy(t *testing.T) {\n\tscenarios := []struct {\n\t\tName            string\n\t\tDefaultFilterBy string\n\t\tExpectedError   error\n\t\tExpectedValue   string\n\t}{\n\t\t{\n\t\t\tName:            \"EmptyDefaultFilterBy\",\n\t\t\tDefaultFilterBy: \"\",\n\t\t\tExpectedError:   nil,\n\t\t\tExpectedValue:   defaultFilterBy,\n\t\t},\n\t\t{\n\t\t\tName:            \"ValidDefaultFilterBy_none\",\n\t\t\tDefaultFilterBy: \"none\",\n\t\t\tExpectedError:   nil,\n\t\t\tExpectedValue:   \"none\",\n\t\t},\n\t\t{\n\t\t\tName:            \"ValidDefaultFilterBy_failing\",\n\t\t\tDefaultFilterBy: \"failing\",\n\t\t\tExpectedError:   nil,\n\t\t\tExpectedValue:   \"failing\",\n\t\t},\n\t\t{\n\t\t\tName:            \"ValidDefaultFilterBy_unstable\",\n\t\t\tDefaultFilterBy: \"unstable\",\n\t\t\tExpectedError:   nil,\n\t\t\tExpectedValue:   \"unstable\",\n\t\t},\n\t\t{\n\t\t\tName:            \"InvalidDefaultFilterBy\",\n\t\t\tDefaultFilterBy: \"invalid\",\n\t\t\tExpectedError:   ErrInvalidDefaultFilterBy,\n\t\t\tExpectedValue:   \"invalid\",\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tcfg := &Config{DefaultFilterBy: scenario.DefaultFilterBy}\n\t\t\terr := cfg.ValidateAndSetDefaults()\n\t\t\tif !errors.Is(err, scenario.ExpectedError) {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.ExpectedError, err)\n\t\t\t}\n\t\t\tif cfg.DefaultFilterBy != scenario.ExpectedValue {\n\t\t\t\tt.Errorf(\"expected DefaultFilterBy to be %s, got %s\", scenario.ExpectedValue, cfg.DefaultFilterBy)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "config/util.go",
    "content": "package config\n\n// toPtr returns a pointer to the given value\nfunc toPtr[T any](value T) *T {\n\treturn &value\n}\n"
  },
  {
    "path": "config/web/web.go",
    "content": "package web\n\nimport (\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n)\n\nconst (\n\t// DefaultAddress is the default address the application will bind to\n\tDefaultAddress = \"0.0.0.0\"\n\n\t// DefaultPort is the default port the application will listen on\n\tDefaultPort = 8080\n\n\t// DefaultReadBufferSize is the default value for ReadBufferSize\n\tDefaultReadBufferSize = 8192\n\n\t// MinimumReadBufferSize is the minimum value for ReadBufferSize, and also the default value set\n\t// for fiber.Config.ReadBufferSize\n\tMinimumReadBufferSize = 4096\n)\n\n// Config is the structure which supports the configuration of the server listening to requests\ntype Config struct {\n\t// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)\n\tAddress string `yaml:\"address\"`\n\n\t// Port to listen on (default to 8080 specified by DefaultPort)\n\tPort int `yaml:\"port\"`\n\n\t// ReadBufferSize sets fiber.Config.ReadBufferSize, which is the buffer size for reading requests coming from a\n\t// single connection and also acts as a limit for the maximum header size.\n\t//\n\t// If you're getting occasional \"Request Header Fields Too Large\", you may want to try increasing this value.\n\t//\n\t// Defaults to DefaultReadBufferSize\n\tReadBufferSize int `yaml:\"read-buffer-size,omitempty\"`\n\n\t// TLS configuration (optional)\n\tTLS *TLSConfig `yaml:\"tls,omitempty\"`\n}\n\ntype TLSConfig struct {\n\t// CertificateFile is the public certificate for TLS in PEM format.\n\tCertificateFile string `yaml:\"certificate-file,omitempty\"`\n\n\t// PrivateKeyFile is the private key file for TLS in PEM format.\n\tPrivateKeyFile string `yaml:\"private-key-file,omitempty\"`\n}\n\n// GetDefaultConfig returns a Config struct with the default values\nfunc GetDefaultConfig() *Config {\n\treturn &Config{\n\t\tAddress:        DefaultAddress,\n\t\tPort:           DefaultPort,\n\t\tReadBufferSize: DefaultReadBufferSize,\n\t}\n}\n\n// ValidateAndSetDefaults validates the web configuration and sets the default values if necessary.\nfunc (web *Config) ValidateAndSetDefaults() error {\n\t// Validate the Address\n\tif len(web.Address) == 0 {\n\t\tweb.Address = DefaultAddress\n\t}\n\t// Validate the Port\n\tif web.Port == 0 {\n\t\tweb.Port = DefaultPort\n\t} else if web.Port < 0 || web.Port > math.MaxUint16 {\n\t\treturn fmt.Errorf(\"invalid port: value should be between %d and %d\", 0, math.MaxUint16)\n\t}\n\t// Validate ReadBufferSize\n\tif web.ReadBufferSize == 0 {\n\t\tweb.ReadBufferSize = DefaultReadBufferSize // Not set? Use the default value.\n\t} else if web.ReadBufferSize < MinimumReadBufferSize {\n\t\tweb.ReadBufferSize = MinimumReadBufferSize // Below the minimum? Use the minimum value.\n\t}\n\t// Try to load the TLS certificates\n\tif web.TLS != nil {\n\t\tif err := web.TLS.isValid(); err != nil {\n\t\t\treturn fmt.Errorf(\"invalid tls config: %w\", err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (web *Config) HasTLS() bool {\n\treturn web.TLS != nil && len(web.TLS.CertificateFile) > 0 && len(web.TLS.PrivateKeyFile) > 0\n}\n\n// SocketAddress returns the combination of the Address and the Port\nfunc (web *Config) SocketAddress() string {\n\treturn fmt.Sprintf(\"%s:%d\", web.Address, web.Port)\n}\n\nfunc (t *TLSConfig) isValid() error {\n\tif len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 {\n\t\t_, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}\n\treturn errors.New(\"certificate-file and private-key-file must be specified\")\n}\n"
  },
  {
    "path": "config/web/web_test.go",
    "content": "package web\n\nimport (\n\t\"testing\"\n)\n\nfunc TestGetDefaultConfig(t *testing.T) {\n\tdefaultConfig := GetDefaultConfig()\n\tif defaultConfig.Port != DefaultPort {\n\t\tt.Error(\"expected default config to have the default port\")\n\t}\n\tif defaultConfig.Address != DefaultAddress {\n\t\tt.Error(\"expected default config to have the default address\")\n\t}\n\tif defaultConfig.ReadBufferSize != DefaultReadBufferSize {\n\t\tt.Error(\"expected default config to have the default read buffer size\")\n\t}\n\tif defaultConfig.TLS != nil {\n\t\tt.Error(\"expected default config to have TLS disabled\")\n\t}\n}\n\nfunc TestConfig_ValidateAndSetDefaults(t *testing.T) {\n\tscenarios := []struct {\n\t\tname                   string\n\t\tcfg                    *Config\n\t\texpectedAddress        string\n\t\texpectedPort           int\n\t\texpectedReadBufferSize int\n\t\texpectedErr            bool\n\t}{\n\t\t{\n\t\t\tname:                   \"no-explicit-config\",\n\t\t\tcfg:                    &Config{},\n\t\t\texpectedAddress:        \"0.0.0.0\",\n\t\t\texpectedPort:           8080,\n\t\t\texpectedReadBufferSize: 8192,\n\t\t\texpectedErr:            false,\n\t\t},\n\t\t{\n\t\t\tname:        \"invalid-port\",\n\t\t\tcfg:         &Config{Port: 100000000},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:                   \"read-buffer-size-below-minimum\",\n\t\t\tcfg:                    &Config{ReadBufferSize: 1024},\n\t\t\texpectedAddress:        \"0.0.0.0\",\n\t\t\texpectedPort:           8080,\n\t\t\texpectedReadBufferSize: MinimumReadBufferSize, // minimum is 4096, default is 8192.\n\t\t\texpectedErr:            false,\n\t\t},\n\t\t{\n\t\t\tname:                   \"read-buffer-size-at-minimum\",\n\t\t\tcfg:                    &Config{ReadBufferSize: MinimumReadBufferSize},\n\t\t\texpectedAddress:        \"0.0.0.0\",\n\t\t\texpectedPort:           8080,\n\t\t\texpectedReadBufferSize: 4096,\n\t\t\texpectedErr:            false,\n\t\t},\n\t\t{\n\t\t\tname:                   \"custom-read-buffer-size\",\n\t\t\tcfg:                    &Config{ReadBufferSize: 65536},\n\t\t\texpectedAddress:        \"0.0.0.0\",\n\t\t\texpectedPort:           8080,\n\t\t\texpectedReadBufferSize: 65536,\n\t\t\texpectedErr:            false,\n\t\t},\n\t\t{\n\t\t\tname:                   \"with-good-tls-config\",\n\t\t\tcfg:                    &Config{Port: 443, TLS: &TLSConfig{CertificateFile: \"../../testdata/cert.pem\", PrivateKeyFile: \"../../testdata/cert.key\"}},\n\t\t\texpectedAddress:        \"0.0.0.0\",\n\t\t\texpectedPort:           443,\n\t\t\texpectedReadBufferSize: 8192,\n\t\t\texpectedErr:            false,\n\t\t},\n\t\t{\n\t\t\tname:                   \"with-bad-tls-config\",\n\t\t\tcfg:                    &Config{Port: 443, TLS: &TLSConfig{CertificateFile: \"../../testdata/badcert.pem\", PrivateKeyFile: \"../../testdata/cert.key\"}},\n\t\t\texpectedAddress:        \"0.0.0.0\",\n\t\t\texpectedPort:           443,\n\t\t\texpectedReadBufferSize: 8192,\n\t\t\texpectedErr:            true,\n\t\t},\n\t\t{\n\t\t\tname:                   \"with-partial-tls-config\",\n\t\t\tcfg:                    &Config{Port: 443, TLS: &TLSConfig{CertificateFile: \"\", PrivateKeyFile: \"../../testdata/cert.key\"}},\n\t\t\texpectedAddress:        \"0.0.0.0\",\n\t\t\texpectedPort:           443,\n\t\t\texpectedReadBufferSize: 8192,\n\t\t\texpectedErr:            true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.cfg.ValidateAndSetDefaults()\n\t\t\tif (err != nil) != scenario.expectedErr {\n\t\t\t\tt.Errorf(\"expected the existence of an error to be %v, got %v\", scenario.expectedErr, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !scenario.expectedErr {\n\t\t\t\tif scenario.cfg.Port != scenario.expectedPort {\n\t\t\t\t\tt.Errorf(\"expected Port to be %d, got %d\", scenario.expectedPort, scenario.cfg.Port)\n\t\t\t\t}\n\t\t\t\tif scenario.cfg.ReadBufferSize != scenario.expectedReadBufferSize {\n\t\t\t\t\tt.Errorf(\"expected ReadBufferSize to be %d, got %d\", scenario.expectedReadBufferSize, scenario.cfg.ReadBufferSize)\n\t\t\t\t}\n\t\t\t\tif scenario.cfg.Address != scenario.expectedAddress {\n\t\t\t\t\tt.Errorf(\"expected Address to be %s, got %s\", scenario.expectedAddress, scenario.cfg.Address)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfig_SocketAddress(t *testing.T) {\n\tweb := &Config{\n\t\tAddress: \"0.0.0.0\",\n\t\tPort:    8081,\n\t}\n\tif web.SocketAddress() != \"0.0.0.0:8081\" {\n\t\tt.Errorf(\"expected %s, got %s\", \"0.0.0.0:8081\", web.SocketAddress())\n\t}\n}\n\nfunc TestConfig_isValid(t *testing.T) {\n\tscenarios := []struct {\n\t\tname        string\n\t\tcfg         *Config\n\t\texpectedErr bool\n\t}{\n\t\t{\n\t\t\tname:        \"good-tls-config\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../../testdata/cert.pem\", PrivateKeyFile: \"../../testdata/cert.key\"}},\n\t\t\texpectedErr: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"missing-certificate-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"doesnotexist\", PrivateKeyFile: \"../../testdata/cert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"bad-certificate-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../../testdata/badcert.pem\", PrivateKeyFile: \"../../testdata/cert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"no-certificate-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"\", PrivateKeyFile: \"../../testdata/cert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"missing-private-key-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../../testdata/cert.pem\", PrivateKeyFile: \"doesnotexist\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"no-private-key-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../../testdata/cert.pem\", PrivateKeyFile: \"\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"bad-private-key-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../../testdata/cert.pem\", PrivateKeyFile: \"../../testdata/badcert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"bad-certificate-and-private-key-file\",\n\t\t\tcfg:         &Config{TLS: &TLSConfig{CertificateFile: \"../../testdata/badcert.pem\", PrivateKeyFile: \"../../testdata/badcert.key\"}},\n\t\t\texpectedErr: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\terr := scenario.cfg.ValidateAndSetDefaults()\n\t\t\tif (err != nil) != scenario.expectedErr {\n\t\t\t\tt.Errorf(\"expected the existence of an error to be %v, got %v\", scenario.expectedErr, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !scenario.expectedErr {\n\t\t\t\tif scenario.cfg.TLS.isValid() != nil {\n\t\t\t\t\tt.Error(\"cfg.TLS.isValid() returned an error even though no error was expected\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "controller/controller.go",
    "content": "package controller\n\nimport (\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/api\"\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/logr\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\nvar (\n\tapp *fiber.App\n)\n\n// Handle creates the router and starts the server\nfunc Handle(cfg *config.Config) {\n\tapi := api.New(cfg)\n\tapp = api.Router()\n\tserver := app.Server()\n\tserver.ReadTimeout = 15 * time.Second\n\tserver.WriteTimeout = 15 * time.Second\n\tserver.IdleTimeout = 15 * time.Second\n\tif os.Getenv(\"ROUTER_TEST\") == \"true\" {\n\t\treturn\n\t}\n\tlogr.Info(\"[controller.Handle] Listening on \" + cfg.Web.SocketAddress())\n\tif cfg.Web.HasTLS() {\n\t\terr := app.ListenTLS(cfg.Web.SocketAddress(), cfg.Web.TLS.CertificateFile, cfg.Web.TLS.PrivateKeyFile)\n\t\tif err != nil {\n\t\t\tlogr.Fatalf(\"[controller.Handle] %s\", err.Error())\n\t\t}\n\t} else {\n\t\terr := app.Listen(cfg.Web.SocketAddress())\n\t\tif err != nil {\n\t\t\tlogr.Fatalf(\"[controller.Handle] %s\", err.Error())\n\t\t}\n\t}\n\tlogr.Info(\"[controller.Handle] Server has shut down successfully\")\n}\n\n// Shutdown stops the server\nfunc Shutdown() {\n\tif app != nil {\n\t\t_ = app.Shutdown()\n\t\tapp = nil\n\t}\n}\n"
  },
  {
    "path": "controller/controller_test.go",
    "content": "package controller\n\nimport (\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/web\"\n\t\"github.com/gofiber/fiber/v2\"\n)\n\nfunc TestHandle(t *testing.T) {\n\tcfg := &config.Config{\n\t\tWeb: &web.Config{\n\t\t\tAddress: \"0.0.0.0\",\n\t\t\tPort:    rand.Intn(65534),\n\t\t},\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName:  \"frontend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:  \"backend\",\n\t\t\t\tGroup: \"core\",\n\t\t\t},\n\t\t},\n\t}\n\t_ = os.Setenv(\"ROUTER_TEST\", \"true\")\n\t_ = os.Setenv(\"ENVIRONMENT\", \"dev\")\n\tdefer os.Clearenv()\n\tHandle(cfg)\n\tdefer Shutdown()\n\trequest := httptest.NewRequest(\"GET\", \"/health\", http.NoBody)\n\tresponse, err := app.Test(request)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif response.StatusCode != 200 {\n\t\tt.Error(\"expected GET /health to return status code 200\")\n\t}\n\tif app == nil {\n\t\tt.Fatal(\"server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)\")\n\t}\n}\n\nfunc TestHandleTLS(t *testing.T) {\n\tscenarios := []struct {\n\t\tname               string\n\t\ttls                *web.TLSConfig\n\t\texpectedStatusCode int\n\t}{\n\t\t{\n\t\t\tname:               \"good-tls-config\",\n\t\t\ttls:                &web.TLSConfig{CertificateFile: \"../testdata/cert.pem\", PrivateKeyFile: \"../testdata/cert.key\"},\n\t\t\texpectedStatusCode: 200,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.name, func(t *testing.T) {\n\t\t\tcfg := &config.Config{\n\t\t\t\tWeb: &web.Config{Address: \"0.0.0.0\", Port: rand.Intn(65534), TLS: scenario.tls},\n\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t{Name: \"frontend\", Group: \"core\"},\n\t\t\t\t\t{Name: \"backend\", Group: \"core\"},\n\t\t\t\t},\n\t\t\t}\n\t\t\tif err := cfg.Web.ValidateAndSetDefaults(); err != nil {\n\t\t\t\tt.Error(\"expected no error from web (TLS) validation, got\", err)\n\t\t\t}\n\t\t\t_ = os.Setenv(\"ROUTER_TEST\", \"true\")\n\t\t\t_ = os.Setenv(\"ENVIRONMENT\", \"dev\")\n\t\t\tdefer os.Clearenv()\n\t\t\tHandle(cfg)\n\t\t\tdefer Shutdown()\n\t\t\trequest := httptest.NewRequest(\"GET\", \"/health\", http.NoBody)\n\t\t\tresponse, err := app.Test(request)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tif response.StatusCode != scenario.expectedStatusCode {\n\t\t\t\tt.Errorf(\"%s %s should have returned %d, but returned %d instead\", request.Method, request.URL, scenario.expectedStatusCode, response.StatusCode)\n\t\t\t}\n\t\t\tif app == nil {\n\t\t\t\tt.Fatal(\"server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestShutdown(t *testing.T) {\n\t// Pretend that we called controller.Handle(), which initializes the server variable\n\tapp = fiber.New()\n\tShutdown()\n\tif app != nil {\n\t\tt.Error(\"server should've been shut down\")\n\t}\n}\n"
  },
  {
    "path": "docs/pagerduty-integration-guide.md",
    "content": "# PagerDuty + Gatus Integration Benefits\n- Notify on-call responders based on alerts sent from Gatus.\n- Incidents will automatically resolve in PagerDuty when the endpoint that caused the incident in Gatus returns to a healthy state.\n\n\n# How it Works\n- 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.\n- 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.\n\n\n# Requirements\n- 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.\n\n\n# Support\nIf you need help with this integration, please create an issue at https://github.com/TwiN/gatus/issues\n\n\n# Integration Walkthrough\n## In PagerDuty\n### Integrating With a PagerDuty Service\n1. From the **Configuration** menu, select **Services**.\n2. There are two ways to add an integration to a service:\n   * **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.\n   * **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.\n3. Enter an **Integration Name** in the format `gatus-service-name` (e.g. `Gatus-Shopping-Cart`) and select **Gatus** from the Integration Type menu.\n4. Click the **Add Integration** button to save your new integration. You will be redirected to the Integrations tab for your service.\n5. 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.\n![PagerDuty Integration Key](https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/pagerduty-integration-key.png)\n\n\n## In Gatus\nIn your configuration file, you must first specify the integration key at `alerting.pagerduty.integration-key`, like so:\n```yaml\nalerting:\n  pagerduty: \n    integration-key: \"********************************\"\n```\nYou can now add alerts of type `pagerduty` in the endpoint you've defined, like so:\n```yaml\nendpoints:\n  - name: website\n    interval: 30s\n    url: \"https://twin.sh/health\"\n    alerts:\n      - type: pagerduty\n        enabled: true\n        failure-threshold: 3\n        success-threshold: 5\n        description: \"healthcheck failed 3 times in a row\"\n        send-on-resolved: true\n    conditions:\n      - \"[STATUS] == 200\"\n      - \"[BODY].status == UP\"\n      - \"[RESPONSE_TIME] < 300\"\n```\n\nThe sample above will do the following:\n- Send a request to the `https://twin.sh/health` (`endpoints[].url`) specified every **30s** (`endpoints[].interval`)\n- Evaluate the conditions to determine whether the endpoint is \"healthy\" or not\n- **If all conditions are not met 3 (`endpoints[].alerts[].failure-threshold`) times in a row**: Gatus will create a new incident\n- **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\n\nIt is highly recommended to set `endpoints[].alerts[].send-on-resolved` to true for alerts of type `pagerduty`.\n\n\n# How to Uninstall\n1. Navigate to the PagerDuty service you'd like to uninstall the Gatus integration from\n2. Click on the **Integration** tab\n3. Click on the **Gatus** integration\n4. Click on **Delete Integration**\n\nWhile the above will prevent incidents from being created, you are also highly encouraged to disable the alerts\nin your Gatus configuration files or simply remove the integration key from the configuration file.\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/TwiN/gatus/v5\n\ngo 1.25.5\n\nrequire (\n\tcode.gitea.io/sdk/gitea v0.23.2\n\tgithub.com/TwiN/deepmerge v0.2.2\n\tgithub.com/TwiN/g8/v2 v2.0.0\n\tgithub.com/TwiN/gocache/v2 v2.4.0\n\tgithub.com/TwiN/health v1.6.0\n\tgithub.com/TwiN/logr v0.3.1\n\tgithub.com/TwiN/whois v1.3.0\n\tgithub.com/aws/aws-sdk-go-v2 v1.41.1\n\tgithub.com/aws/aws-sdk-go-v2/config v1.32.7\n\tgithub.com/aws/aws-sdk-go-v2/credentials v1.19.7\n\tgithub.com/aws/aws-sdk-go-v2/service/ses v1.34.18\n\tgithub.com/coreos/go-oidc/v3 v3.17.0\n\tgithub.com/gofiber/fiber/v2 v2.52.11\n\tgithub.com/google/go-github/v48 v48.2.0\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2\n\tgithub.com/lib/pq v1.11.1\n\tgithub.com/miekg/dns v1.1.72\n\tgithub.com/prometheus-community/pro-bing v0.8.0\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/registrobr/rdap v1.1.8\n\tgithub.com/valyala/fasthttp v1.69.0\n\tgithub.com/wcharczuk/go-chart/v2 v2.1.2\n\tgolang.org/x/crypto v0.47.0\n\tgolang.org/x/oauth2 v0.35.0\n\tgolang.org/x/sync v0.19.0\n\tgoogle.golang.org/api v0.265.0\n\tgoogle.golang.org/grpc v1.78.0\n\tgopkg.in/mail.v2 v2.3.1\n\tgopkg.in/yaml.v3 v3.0.1\n\tmodernc.org/sqlite v1.44.3\n)\n\nrequire (\n\tcloud.google.com/go/auth v0.18.1 // indirect\n\tcloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect\n\tcloud.google.com/go/compute/metadata v0.9.0 // indirect\n\tgithub.com/42wim/httpsig v1.2.3 // indirect\n\tgithub.com/andybalholm/brotli v1.2.0 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect\n\tgithub.com/aws/smithy-go v1.24.0 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/clipperhouse/stringish v0.1.1 // indirect\n\tgithub.com/clipperhouse/uax29/v2 v2.4.0 // indirect\n\tgithub.com/davidmz/go-pageant v1.0.2 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/go-fed/httpsig v1.1.0 // indirect\n\tgithub.com/go-jose/go-jose/v4 v4.1.3 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect\n\tgithub.com/google/go-querystring v1.1.0 // indirect\n\tgithub.com/google/s2a-go v0.1.9 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.16.0 // indirect\n\tgithub.com/hashicorp/go-version v1.8.0 // indirect\n\tgithub.com/klauspost/compress v1.18.3 // indirect\n\tgithub.com/kylelemons/godebug v1.1.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.19 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\n\tgithub.com/prometheus/client_model v0.6.2 // indirect\n\tgithub.com/prometheus/common v0.66.1 // indirect\n\tgithub.com/prometheus/procfs v0.16.1 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/valyala/bytebufferpool v1.0.0 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect\n\tgo.opentelemetry.io/otel v1.39.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.39.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.39.0 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.2 // indirect\n\tgolang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect\n\tgolang.org/x/image v0.35.0 // indirect\n\tgolang.org/x/mod v0.32.0 // indirect\n\tgolang.org/x/net v0.49.0 // indirect\n\tgolang.org/x/sys v0.40.0 // indirect\n\tgolang.org/x/text v0.33.0 // indirect\n\tgolang.org/x/tools v0.41.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect\n\tgoogle.golang.org/protobuf v1.36.11 // indirect\n\tgopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect\n\tmodernc.org/libc v1.67.7 // indirect\n\tmodernc.org/mathutil v1.7.1 // indirect\n\tmodernc.org/memory v1.11.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=\ncloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=\ncloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=\ncloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=\ncloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=\ncloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=\ncode.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=\ncode.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=\ngithub.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=\ngithub.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=\ngithub.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=\ngithub.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=\ngithub.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=\ngithub.com/TwiN/g8/v2 v2.0.0/go.mod h1:4sVAF27q8T8ISggRa/Fb0drw7wpB22B6eWd+/+SGMqE=\ngithub.com/TwiN/gocache/v2 v2.4.0 h1:BZ/TqvhipDQE23MFFTjC0MiI1qZ7GEVtSdOFVVXyr18=\ngithub.com/TwiN/gocache/v2 v2.4.0/go.mod h1:Cl1c0qNlQlXzJhTpAARVqpQDSuGDM5RhtzPYAM1x17g=\ngithub.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM=\ngithub.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=\ngithub.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=\ngithub.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw=\ngithub.com/TwiN/whois v1.3.0 h1:V2+IUh5OGim8F3axTOMSlVmxNRaBrgpyiv5U0Dvbt5I=\ngithub.com/TwiN/whois v1.3.0/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=\ngithub.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=\ngithub.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=\ngithub.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=\ngithub.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=\ngithub.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=\ngithub.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=\ngithub.com/aws/aws-sdk-go-v2/service/ses v1.34.18 h1:2Lnd3ZNTyWpFJJM55y0mP0aESovm+vFuFEwLijucUL8=\ngithub.com/aws/aws-sdk-go-v2/service/ses v1.34.18/go.mod h1:BLwHw6wdkA6NfnW/cFaVcvpwdIXHLAkpe6nsLF9BVww=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=\ngithub.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=\ngithub.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=\ngithub.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=\ngithub.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=\ngithub.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=\ngithub.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=\ngithub.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=\ngithub.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=\ngithub.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=\ngithub.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=\ngithub.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=\ngithub.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=\ngithub.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=\ngithub.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE=\ngithub.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y=\ngithub.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=\ngithub.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=\ngithub.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=\ngithub.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=\ngithub.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=\ngithub.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2 h1:36qep4gxKs+JgeHGWeQ040RyZdt9kQlLglL1rFVn/oQ=\ngithub.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=\ngithub.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=\ngithub.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=\ngithub.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=\ngithub.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=\ngithub.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=\ngithub.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=\ngithub.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=\ngithub.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=\ngithub.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=\ngithub.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=\ngithub.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=\ngithub.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=\ngithub.com/registrobr/rdap v1.1.8/go.mod h1:VY2DVrpsJpUfy9gj2QvurGymCgZV11/11cxQz5CxO+w=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=\ngithub.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=\ngithub.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=\ngithub.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=\ngithub.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=\ngithub.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=\ngithub.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=\ngithub.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=\ngo.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=\ngo.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=\ngo.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=\ngo.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=\ngo.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=\ngo.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=\ngo.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=\ngo.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=\ngo.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=\ngo.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=\ngo.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=\ngolang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=\ngolang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=\ngolang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=\ngolang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=\ngolang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=\ngolang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=\ngolang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=\ngolang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=\ngolang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=\ngolang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=\ngolang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=\ngolang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=\ngolang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=\ngolang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=\ngolang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=\ngolang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=\ngolang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=\ngolang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=\ngolang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=\ngolang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=\ngolang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=\ngolang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=\ngolang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=\ngolang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=\ngolang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=\ngonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=\ngoogle.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=\ngoogle.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=\ngoogle.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=\ngoogle.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=\ngopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=\ngopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nmodernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=\nmodernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=\nmodernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=\nmodernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=\nmodernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=\nmodernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=\nmodernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=\nmodernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\n"
  },
  {
    "path": "jsonpath/jsonpath.go",
    "content": "package jsonpath\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// Eval is a half-baked json path implementation that needs some love\nfunc Eval(path string, b []byte) (string, int, error) {\n\tif len(path) == 0 && !(len(b) != 0 && b[0] == '[' && b[len(b)-1] == ']') {\n\t\t// if there's no path AND the value is not a JSON array, then there's nothing to walk\n\t\treturn string(b), len(b), nil\n\t}\n\tvar object interface{}\n\tif err := json.Unmarshal(b, &object); err != nil {\n\t\treturn \"\", 0, err\n\t}\n\treturn walk(path, object)\n}\n\n// walk traverses the object and returns the value as a string as well as its length\nfunc walk(path string, object interface{}) (string, int, error) {\n\tvar keys []string\n\tstartOfCurrentKey, bracketDepth := 0, 0\n\tfor i := range path {\n\t\tif path[i] == '[' {\n\t\t\tbracketDepth++\n\t\t} else if path[i] == ']' {\n\t\t\tbracketDepth--\n\t\t}\n\t\t// If we encounter a dot, we've reached the end of a key unless we're inside a bracket\n\t\tif path[i] == '.' && bracketDepth == 0 {\n\t\t\tkeys = append(keys, path[startOfCurrentKey:i])\n\t\t\tstartOfCurrentKey = i + 1\n\t\t}\n\t}\n\tif startOfCurrentKey <= len(path) {\n\t\tkeys = append(keys, path[startOfCurrentKey:])\n\t}\n\tcurrentKey := keys[0]\n\tswitch value := extractValue(currentKey, object).(type) {\n\tcase map[string]interface{}:\n\t\tnewPath := strings.Replace(path, fmt.Sprintf(\"%s.\", currentKey), \"\", 1)\n\t\tif path == newPath {\n\t\t\t// If the path hasn't changed, it means we're at the end of the path\n\t\t\t// So we'll treat it as a string by re-marshaling it to JSON since it's a map.\n\t\t\t// Note that the output JSON will be minified.\n\t\t\tb, err := json.Marshal(value)\n\t\t\treturn string(b), len(b), err\n\t\t}\n\t\treturn walk(newPath, value)\n\tcase string:\n\t\tif len(keys) > 1 {\n\t\t\treturn \"\", 0, fmt.Errorf(\"couldn't walk through '%s', because '%s' was a string instead of an object\", keys[1], currentKey)\n\t\t}\n\t\treturn value, len(value), nil\n\tcase []interface{}:\n\t\treturn fmt.Sprintf(\"%v\", value), len(value), nil\n\tcase interface{}:\n\t\tnewValue := fmt.Sprintf(\"%v\", value)\n\t\treturn newValue, len(newValue), nil\n\tdefault:\n\t\treturn \"\", 0, fmt.Errorf(\"couldn't walk through '%s' because type was '%T', but expected 'map[string]interface{}'\", currentKey, value)\n\t}\n}\n\nfunc extractValue(currentKey string, value interface{}) interface{} {\n\t// Check if the current key ends with [#]\n\tif strings.HasSuffix(currentKey, \"]\") && strings.Contains(currentKey, \"[\") {\n\t\tvar isNestedArray bool\n\t\tvar index string\n\t\tstartOfBracket, endOfBracket, bracketDepth := 0, 0, 0\n\t\tfor i := range currentKey {\n\t\t\tif currentKey[i] == '[' {\n\t\t\t\tstartOfBracket = i\n\t\t\t\tbracketDepth++\n\t\t\t} else if currentKey[i] == ']' && bracketDepth == 1 {\n\t\t\t\tbracketDepth--\n\t\t\t\tendOfBracket = i\n\t\t\t\tindex = currentKey[startOfBracket+1 : i]\n\t\t\t\tif len(currentKey) > i+1 && currentKey[i+1] == '[' {\n\t\t\t\t\tisNestedArray = true // there's more keys.\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tarrayIndex, err := strconv.Atoi(index)\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\tcurrentKeyWithoutIndex := currentKey[:startOfBracket]\n\t\t// if currentKeyWithoutIndex contains only an index (i.e. [0] or 0)\n\t\tif len(currentKeyWithoutIndex) == 0 {\n\t\t\tarray, _ := value.([]interface{})\n\t\t\tif len(array) > arrayIndex {\n\t\t\t\tif isNestedArray {\n\t\t\t\t\treturn extractValue(currentKey[endOfBracket+1:], array[arrayIndex])\n\t\t\t\t}\n\t\t\t\treturn array[arrayIndex]\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tif value == nil || value.(map[string]interface{})[currentKeyWithoutIndex] == nil {\n\t\t\treturn nil\n\t\t}\n\t\t// if currentKeyWithoutIndex contains both a key and an index (i.e. data[0])\n\t\tarray, _ := value.(map[string]interface{})[currentKeyWithoutIndex].([]interface{})\n\t\tif len(array) > arrayIndex {\n\t\t\tif isNestedArray {\n\t\t\t\treturn extractValue(currentKey[endOfBracket+1:], array[arrayIndex])\n\t\t\t}\n\t\t\treturn array[arrayIndex]\n\t\t}\n\t\treturn nil\n\t}\n\tif valueAsSlice, ok := value.([]interface{}); ok {\n\t\t// If the type is a slice, return it\n\t\t// This happens when the body (value) is a JSON array\n\t\treturn valueAsSlice\n\t}\n\tif valueAsMap, ok := value.(map[string]interface{}); ok {\n\t\t// If the value is a map, then we get the currentKey from that map\n\t\t// This happens when the body (value) is a JSON object\n\t\treturn valueAsMap[currentKey]\n\t}\n\t// If the value is neither a map, nor a slice, nor an index, then we cannot retrieve the currentKey\n\t// from said value. This usually happens when the body (value) is null.\n\treturn value\n}\n"
  },
  {
    "path": "jsonpath/jsonpath_bench_test.go",
    "content": "package jsonpath\n\nimport \"testing\"\n\nfunc BenchmarkEval(b *testing.B) {\n\tfor i := 0; i < b.N; i++ {\n\t\tEval(\"ids[0]\", []byte(`{\"ids\": [1, 2]}`))\n\t\tEval(\"long.simple.walk\", []byte(`{\"long\": {\"simple\": {\"walk\": \"value\"}}}`))\n\t\tEval(\"data[0].apps[1].name\", []byte(`{\"data\": [{\"apps\": [{\"name\":\"app1\"}, {\"name\":\"app2\"}, {\"name\":\"app3\"}]}]}`))\n\t}\n}\n"
  },
  {
    "path": "jsonpath/jsonpath_test.go",
    "content": "package jsonpath\n\nimport (\n\t\"testing\"\n)\n\nfunc TestEval(t *testing.T) {\n\ttype Scenario struct {\n\t\tName                 string\n\t\tPath                 string\n\t\tData                 string\n\t\tExpectedOutput       string\n\t\tExpectedOutputLength int\n\t\tExpectedError        bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:                 \"simple\",\n\t\t\tPath:                 \"key\",\n\t\t\tData:                 `{\"key\": \"value\"}`,\n\t\t\tExpectedOutput:       \"value\",\n\t\t\tExpectedOutputLength: 5,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"simple-with-invalid-data\",\n\t\t\tPath:                 \"key\",\n\t\t\tData:                 \"invalid data\",\n\t\t\tExpectedOutput:       \"\",\n\t\t\tExpectedOutputLength: 0,\n\t\t\tExpectedError:        true,\n\t\t},\n\t\t{\n\t\t\tName:                 \"invalid-path\",\n\t\t\tPath:                 \"key\",\n\t\t\tData:                 `{}`,\n\t\t\tExpectedOutput:       \"\",\n\t\t\tExpectedOutputLength: 0,\n\t\t\tExpectedError:        true,\n\t\t},\n\t\t{\n\t\t\tName:                 \"long-simple-walk\",\n\t\t\tPath:                 \"long.simple.walk\",\n\t\t\tData:                 `{\"long\": {\"simple\": {\"walk\": \"value\"}}}`,\n\t\t\tExpectedOutput:       \"value\",\n\t\t\tExpectedOutputLength: 5,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"array-of-objects\",\n\t\t\tPath:                 \"ids[1].id\",\n\t\t\tData:                 `{\"ids\": [{\"id\": 1}, {\"id\": 2}]}`,\n\t\t\tExpectedOutput:       \"2\",\n\t\t\tExpectedOutputLength: 1,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"array-of-values\",\n\t\t\tPath:                 \"ids[0]\",\n\t\t\tData:                 `{\"ids\": [1, 2]}`,\n\t\t\tExpectedOutput:       \"1\",\n\t\t\tExpectedOutputLength: 1,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"array-of-values-with-no-path\",\n\t\t\tPath:                 \"\",\n\t\t\tData:                 `[1, 2]`,\n\t\t\tExpectedOutput:       \"[1 2]\", // the output is an array\n\t\t\tExpectedOutputLength: 2,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"array-of-values-and-invalid-index\",\n\t\t\tPath:                 \"ids[wat]\",\n\t\t\tData:                 `{\"ids\": [1, 2]}`,\n\t\t\tExpectedOutput:       \"\",\n\t\t\tExpectedOutputLength: 0,\n\t\t\tExpectedError:        true,\n\t\t},\n\t\t{\n\t\t\tName:                 \"array-of-values-at-root\",\n\t\t\tPath:                 \"[1]\",\n\t\t\tData:                 `[1, 2]`,\n\t\t\tExpectedOutput:       \"2\",\n\t\t\tExpectedOutputLength: 1,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"array-of-objects-at-root\",\n\t\t\tPath:                 \"[0]\",\n\t\t\tData:                 `[{\"id\": 1}, {\"id\": 2}]`,\n\t\t\tExpectedOutput:       `{\"id\":1}`,\n\t\t\tExpectedOutputLength: 8,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"array-of-objects-with-int-at-root\",\n\t\t\tPath:                 \"[0].id\",\n\t\t\tData:                 `[{\"id\": 1}, {\"id\": 2}]`,\n\t\t\tExpectedOutput:       \"1\",\n\t\t\tExpectedOutputLength: 1,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"array-of-objects-at-root-and-invalid-index\",\n\t\t\tPath:                 \"[5].id\",\n\t\t\tData:                 `[{\"id\": 1}, {\"id\": 2}]`,\n\t\t\tExpectedOutput:       \"\",\n\t\t\tExpectedOutputLength: 0,\n\t\t\tExpectedError:        true,\n\t\t},\n\t\t{\n\t\t\tName:                 \"long-walk-and-array\",\n\t\t\tPath:                 \"data.ids[0].id\",\n\t\t\tData:                 `{\"data\": {\"ids\": [{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}}`,\n\t\t\tExpectedOutput:       \"1\",\n\t\t\tExpectedOutputLength: 1,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"nested-array\",\n\t\t\tPath:                 \"[3][2]\",\n\t\t\tData:                 `[[1, 2], [3, 4], [], [5, 6, 7]]`,\n\t\t\tExpectedOutput:       \"7\",\n\t\t\tExpectedOutputLength: 1,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"object-with-nested-arrays\",\n\t\t\tPath:                 \"data[1][1]\",\n\t\t\tData:                 `{\"data\": [[\"a\", \"b\", \"c\"], [\"d\", \"eeeee\", \"f\"]]}`,\n\t\t\tExpectedOutput:       \"eeeee\",\n\t\t\tExpectedOutputLength: 5,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"object-with-arrays-of-objects\",\n\t\t\tPath:                 \"data[0].apps[1].name\",\n\t\t\tData:                 `{\"data\": [{\"apps\": [{\"name\":\"app1\"}, {\"name\":\"app2\"}, {\"name\":\"app3\"}]}]}`,\n\t\t\tExpectedOutput:       \"app2\",\n\t\t\tExpectedOutputLength: 4,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"object-with-arrays-of-objects-with-missing-element\",\n\t\t\tPath:                 \"data[0].apps[1].name\",\n\t\t\tData:                 `{\"data\": [{\"apps\": []}]}`,\n\t\t\tExpectedOutput:       \"\",\n\t\t\tExpectedOutputLength: 0,\n\t\t\tExpectedError:        true,\n\t\t},\n\t\t{\n\t\t\tName:                 \"partially-invalid-path-issue122\",\n\t\t\tPath:                 \"data.name.invalid\",\n\t\t\tData:                 `{\"data\": {\"name\": \"john\"}}`,\n\t\t\tExpectedOutput:       \"\",\n\t\t\tExpectedOutputLength: 0,\n\t\t\tExpectedError:        true,\n\t\t},\n\t\t{\n\t\t\tName:                 \"float-as-string\",\n\t\t\tPath:                 \"balance\",\n\t\t\tData:                 `{\"balance\": \"123.40000000000005\"}`,\n\t\t\tExpectedOutput:       \"123.40000000000005\",\n\t\t\tExpectedOutputLength: 18,\n\t\t\tExpectedError:        false,\n\t\t},\n\t\t{\n\t\t\tName:                 \"float-as-number\",\n\t\t\tPath:                 \"balance\",\n\t\t\tData:                 `{\"balance\": 123.40000000000005}`,\n\t\t\tExpectedOutput:       \"123.40000000000005\",\n\t\t\tExpectedOutputLength: 18,\n\t\t\tExpectedError:        false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\toutput, outputLength, err := Eval(scenario.Path, []byte(scenario.Data))\n\t\t\tif (err != nil) != scenario.ExpectedError {\n\t\t\t\tif scenario.ExpectedError {\n\t\t\t\t\tt.Errorf(\"Expected error, got '%v'\", err)\n\t\t\t\t} else {\n\t\t\t\t\tt.Errorf(\"Expected no error, got '%v'\", err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif outputLength != scenario.ExpectedOutputLength {\n\t\t\t\tt.Errorf(\"Expected output length to be %v, but was %v\", scenario.ExpectedOutputLength, outputLength)\n\t\t\t}\n\t\t\tif output != scenario.ExpectedOutput {\n\t\t\t\tt.Errorf(\"Expected output to be %v, but was %v\", scenario.ExpectedOutput, output)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"os\"\n\t\"os/signal\"\n\t\"strconv\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/controller\"\n\t\"github.com/TwiN/gatus/v5/metrics\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/gatus/v5/watchdog\"\n\t\"github.com/TwiN/logr\"\n)\n\nconst (\n\tGatusConfigPathEnvVar = \"GATUS_CONFIG_PATH\"\n\tGatusConfigFileEnvVar = \"GATUS_CONFIG_FILE\" // Deprecated in favor of GatusConfigPathEnvVar\n\tGatusLogLevelEnvVar   = \"GATUS_LOG_LEVEL\"\n)\n\nfunc main() {\n\tif delayInSeconds, _ := strconv.Atoi(os.Getenv(\"GATUS_DELAY_START_SECONDS\")); delayInSeconds > 0 {\n\t\tlogr.Infof(\"Delaying start by %d seconds\", delayInSeconds)\n\t\ttime.Sleep(time.Duration(delayInSeconds) * time.Second)\n\t}\n\tconfigureLogging()\n\tcfg, err := loadConfiguration()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tinitializeStorage(cfg)\n\tstart(cfg)\n\t// Wait for termination signal\n\tsignalChannel := make(chan os.Signal, 1)\n\tdone := make(chan bool, 1)\n\tsignal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM)\n\tgo func() {\n\t\t<-signalChannel\n\t\tlogr.Info(\"Received termination signal, attempting to gracefully shut down\")\n\t\tstop(cfg)\n\t\tsave()\n\t\tdone <- true\n\t}()\n\t<-done\n\tlogr.Info(\"Shutting down\")\n}\n\nfunc start(cfg *config.Config) {\n\tgo controller.Handle(cfg)\n\tmetrics.InitializePrometheusMetrics(cfg, nil)\n\twatchdog.Monitor(cfg)\n\tgo listenToConfigurationFileChanges(cfg)\n}\n\nfunc stop(cfg *config.Config) {\n\twatchdog.Shutdown(cfg)\n\tcontroller.Shutdown()\n\tmetrics.UnregisterPrometheusMetrics()\n\tcloseTunnels(cfg)\n}\n\nfunc save() {\n\tif err := store.Get().Save(); err != nil {\n\t\tlogr.Errorf(\"Failed to save storage provider: %s\", err.Error())\n\t}\n}\n\nfunc configureLogging() {\n\tlogLevelAsString := os.Getenv(GatusLogLevelEnvVar)\n\tif logLevel, err := logr.LevelFromString(logLevelAsString); err != nil {\n\t\tlogr.SetThreshold(logr.LevelInfo)\n\t\tif len(logLevelAsString) == 0 {\n\t\t\tlogr.Infof(\"[main.configureLogging] Defaulting log level to %s\", logr.LevelInfo)\n\t\t} else {\n\t\t\tlogr.Warnf(\"[main.configureLogging] Invalid log level '%s', defaulting to %s\", logLevelAsString, logr.LevelInfo)\n\t\t}\n\t} else {\n\t\tlogr.SetThreshold(logLevel)\n\t\tlogr.Infof(\"[main.configureLogging] Log Level is set to %s\", logr.GetThreshold())\n\t}\n}\n\nfunc loadConfiguration() (*config.Config, error) {\n\tconfigPath := os.Getenv(GatusConfigPathEnvVar)\n\t// Backwards compatibility\n\tif len(configPath) == 0 {\n\t\tif configPath = os.Getenv(GatusConfigFileEnvVar); len(configPath) > 0 {\n\t\t\tlogr.Warnf(\"WARNING: %s is deprecated. Please use %s instead.\", GatusConfigFileEnvVar, GatusConfigPathEnvVar)\n\t\t}\n\t}\n\treturn config.LoadConfiguration(configPath)\n}\n\n// initializeStorage initializes the storage provider\n//\n// Q: \"TwiN, why are you putting this here? Wouldn't it make more sense to have this in the config?!\"\n// A: Yes. Yes it would make more sense to have it in the config package. But I don't want to import\n// the massive SQL dependencies just because I want to import the config, so here we are.\nfunc initializeStorage(cfg *config.Config) {\n\terr := store.Initialize(cfg.Storage)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\t// Remove all SuiteStatuses that represent suites which no longer exist in the configuration\n\tvar suiteKeys []string\n\tfor _, suite := range cfg.Suites {\n\t\tsuiteKeys = append(suiteKeys, suite.Key())\n\t}\n\tnumberOfSuiteStatusesDeleted := store.Get().DeleteAllSuiteStatusesNotInKeys(suiteKeys)\n\tif numberOfSuiteStatusesDeleted > 0 {\n\t\tlogr.Infof(\"[main.initializeStorage] Deleted %d suite statuses because their matching suites no longer existed\", numberOfSuiteStatusesDeleted)\n\t}\n\t// Remove all EndpointStatus that represent endpoints which no longer exist in the configuration\n\tvar keys []string\n\tfor _, ep := range cfg.Endpoints {\n\t\tkeys = append(keys, ep.Key())\n\t}\n\tfor _, ee := range cfg.ExternalEndpoints {\n\t\tkeys = append(keys, ee.Key())\n\t}\n\t// Also add endpoints that are part of suites\n\tfor _, suite := range cfg.Suites {\n\t\tfor _, ep := range suite.Endpoints {\n\t\t\tkeys = append(keys, ep.Key())\n\t\t}\n\t}\n\tlogr.Infof(\"[main.initializeStorage] Total endpoint keys to preserve: %d\", len(keys))\n\tnumberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys)\n\tif numberOfEndpointStatusesDeleted > 0 {\n\t\tlogr.Infof(\"[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed\", numberOfEndpointStatusesDeleted)\n\t}\n\t// Clean up the triggered alerts from the storage provider and load valid triggered endpoint alerts\n\tnumberOfPersistedTriggeredAlertsLoaded := 0\n\tfor _, ep := range cfg.Endpoints {\n\t\tvar checksums []string\n\t\tfor _, alert := range ep.Alerts {\n\t\t\tif alert.IsEnabled() {\n\t\t\t\tchecksums = append(checksums, alert.Checksum())\n\t\t\t}\n\t\t}\n\t\tnumberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums)\n\t\tif numberOfTriggeredAlertsDeleted > 0 {\n\t\t\tlogr.Debugf(\"[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted\", numberOfTriggeredAlertsDeleted, ep.Key())\n\t\t}\n\t\tfor _, alert := range ep.Alerts {\n\t\t\texists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert)\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif exists {\n\t\t\t\talert.Triggered, alert.ResolveKey = true, resolveKey\n\t\t\t\tep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold\n\t\t\t\tnumberOfPersistedTriggeredAlertsLoaded++\n\t\t\t}\n\t\t}\n\t}\n\tfor _, ee := range cfg.ExternalEndpoints {\n\t\tvar checksums []string\n\t\tfor _, alert := range ee.Alerts {\n\t\t\tif alert.IsEnabled() {\n\t\t\t\tchecksums = append(checksums, alert.Checksum())\n\t\t\t}\n\t\t}\n\t\tconvertedEndpoint := ee.ToEndpoint()\n\t\tnumberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(convertedEndpoint, checksums)\n\t\tif numberOfTriggeredAlertsDeleted > 0 {\n\t\t\tlogr.Debugf(\"[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted\", numberOfTriggeredAlertsDeleted, ee.Key())\n\t\t}\n\t\tfor _, alert := range ee.Alerts {\n\t\t\texists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(convertedEndpoint, alert)\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s\", ee.Key(), err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif exists {\n\t\t\t\talert.Triggered, alert.ResolveKey = true, resolveKey\n\t\t\t\tee.NumberOfSuccessesInARow, ee.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold\n\t\t\t\tnumberOfPersistedTriggeredAlertsLoaded++\n\t\t\t}\n\t\t}\n\t}\n\t// Load persisted triggered alerts for suite endpoints\n\tfor _, suite := range cfg.Suites {\n\t\tfor _, ep := range suite.Endpoints {\n\t\t\tvar checksums []string\n\t\t\tfor _, alert := range ep.Alerts {\n\t\t\t\tif alert.IsEnabled() {\n\t\t\t\t\tchecksums = append(checksums, alert.Checksum())\n\t\t\t\t}\n\t\t\t}\n\t\t\tnumberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums)\n\t\t\tif numberOfTriggeredAlertsDeleted > 0 {\n\t\t\t\tlogr.Debugf(\"[main.initializeStorage] Deleted %d triggered alerts for suite endpoint with key=%s because their configurations have been changed or deleted\", numberOfTriggeredAlertsDeleted, ep.Key())\n\t\t\t}\n\t\t\tfor _, alert := range ep.Alerts {\n\t\t\t\texists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogr.Errorf(\"[main.initializeStorage] Failed to get triggered alert for suite endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif exists {\n\t\t\t\t\talert.Triggered, alert.ResolveKey = true, resolveKey\n\t\t\t\t\tep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold\n\t\t\t\t\tnumberOfPersistedTriggeredAlertsLoaded++\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif numberOfPersistedTriggeredAlertsLoaded > 0 {\n\t\tlogr.Infof(\"[main.initializeStorage] Loaded %d persisted triggered alerts\", numberOfPersistedTriggeredAlertsLoaded)\n\t}\n}\n\nfunc closeTunnels(cfg *config.Config) {\n\tif cfg.Tunneling != nil {\n\t\tif err := cfg.Tunneling.Close(); err != nil {\n\t\t\tlogr.Errorf(\"[main.closeTunnels] Error closing SSH tunnels: %v\", err)\n\t\t}\n\t}\n}\n\nfunc listenToConfigurationFileChanges(cfg *config.Config) {\n\tfor {\n\t\ttime.Sleep(30 * time.Second)\n\t\tif cfg.HasLoadedConfigurationBeenModified() {\n\t\t\tlogr.Info(\"[main.listenToConfigurationFileChanges] Configuration file has been modified\")\n\t\t\tstop(cfg)\n\t\t\ttime.Sleep(time.Second) // Wait a bit to make sure everything is done.\n\t\t\tsave()\n\t\t\tupdatedConfig, err := loadConfiguration()\n\t\t\tif err != nil {\n\t\t\t\tif cfg.SkipInvalidConfigUpdate {\n\t\t\t\t\tlogr.Errorf(\"[main.listenToConfigurationFileChanges] Failed to load new configuration: %s\", err.Error())\n\t\t\t\t\tlogr.Error(\"[main.listenToConfigurationFileChanges] The configuration file was updated, but it is not valid. The old configuration will continue being used.\")\n\t\t\t\t\t// Update the last file modification time to avoid trying to process the same invalid configuration again\n\t\t\t\t\tcfg.UpdateLastFileModTime()\n\t\t\t\t\tcontinue\n\t\t\t\t} else {\n\t\t\t\t\tpanic(err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tstore.Get().Close()\n\t\t\tinitializeStorage(updatedConfig)\n\t\t\tstart(updatedConfig)\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "metrics/metrics.go",
    "content": "package metrics\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n)\n\nconst namespace = \"gatus\" // The prefix of the metrics\n\nvar (\n\tresultTotal                        *prometheus.CounterVec\n\tresultDurationSeconds              *prometheus.GaugeVec\n\tresultConnectedTotal               *prometheus.CounterVec\n\tresultCodeTotal                    *prometheus.CounterVec\n\tresultCertificateExpirationSeconds *prometheus.GaugeVec\n\tresultDomainExpirationSeconds      *prometheus.GaugeVec\n\tresultEndpointSuccess              *prometheus.GaugeVec\n\n\t// Suite metrics\n\tsuiteResultTotal           *prometheus.CounterVec\n\tsuiteResultDurationSeconds *prometheus.GaugeVec\n\tsuiteResultSuccess         *prometheus.GaugeVec\n\n\t// Track if metrics have been initialized to prevent duplicate registration\n\tmetricsInitialized bool\n\tcurrentRegisterer  prometheus.Registerer\n)\n\n// UnregisterPrometheusMetrics unregisters all previously registered metrics\nfunc UnregisterPrometheusMetrics() {\n\tif !metricsInitialized || currentRegisterer == nil {\n\t\treturn\n\t}\n\n\t// Unregister all metrics if they exist\n\tif resultTotal != nil {\n\t\tcurrentRegisterer.Unregister(resultTotal)\n\t}\n\tif resultDurationSeconds != nil {\n\t\tcurrentRegisterer.Unregister(resultDurationSeconds)\n\t}\n\tif resultConnectedTotal != nil {\n\t\tcurrentRegisterer.Unregister(resultConnectedTotal)\n\t}\n\tif resultCodeTotal != nil {\n\t\tcurrentRegisterer.Unregister(resultCodeTotal)\n\t}\n\tif resultCertificateExpirationSeconds != nil {\n\t\tcurrentRegisterer.Unregister(resultCertificateExpirationSeconds)\n\t}\n\tif resultDomainExpirationSeconds != nil {\n\t\tcurrentRegisterer.Unregister(resultDomainExpirationSeconds)\n\t}\n\tif resultEndpointSuccess != nil {\n\t\tcurrentRegisterer.Unregister(resultEndpointSuccess)\n\t}\n\n\t// Unregister suite metrics\n\tif suiteResultTotal != nil {\n\t\tcurrentRegisterer.Unregister(suiteResultTotal)\n\t}\n\tif suiteResultDurationSeconds != nil {\n\t\tcurrentRegisterer.Unregister(suiteResultDurationSeconds)\n\t}\n\tif suiteResultSuccess != nil {\n\t\tcurrentRegisterer.Unregister(suiteResultSuccess)\n\t}\n\n\tmetricsInitialized = false\n\tcurrentRegisterer = nil\n}\n\nfunc InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer) {\n\t// If metrics are already initialized, unregister them first\n\tif metricsInitialized {\n\t\tUnregisterPrometheusMetrics()\n\t}\n\n\tif reg == nil {\n\t\treg = prometheus.DefaultRegisterer\n\t}\n\n\t// Store the registerer for later unregistration\n\tcurrentRegisterer = reg\n\n\textraLabels := cfg.GetUniqueExtraMetricLabels()\n\tresultTotal = prometheus.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"results_total\",\n\t\tHelp:      \"Number of results per endpoint\",\n\t}, append([]string{\"key\", \"group\", \"name\", \"type\", \"success\"}, extraLabels...))\n\treg.MustRegister(resultTotal)\n\n\tresultDurationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"results_duration_seconds\",\n\t\tHelp:      \"Duration of the request in seconds\",\n\t}, append([]string{\"key\", \"group\", \"name\", \"type\"}, extraLabels...))\n\treg.MustRegister(resultDurationSeconds)\n\n\tresultConnectedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"results_connected_total\",\n\t\tHelp:      \"Total number of results in which a connection was successfully established\",\n\t}, append([]string{\"key\", \"group\", \"name\", \"type\"}, extraLabels...))\n\treg.MustRegister(resultConnectedTotal)\n\n\tresultCodeTotal = prometheus.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"results_code_total\",\n\t\tHelp:      \"Total number of results by code\",\n\t}, append([]string{\"key\", \"group\", \"name\", \"type\", \"code\"}, extraLabels...))\n\treg.MustRegister(resultCodeTotal)\n\n\tresultCertificateExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"results_certificate_expiration_seconds\",\n\t\tHelp:      \"Number of seconds until the certificate expires\",\n\t}, append([]string{\"key\", \"group\", \"name\", \"type\"}, extraLabels...))\n\treg.MustRegister(resultCertificateExpirationSeconds)\n\n\tresultDomainExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"results_domain_expiration_seconds\",\n\t\tHelp:      \"Number of seconds until the domain expires\",\n\t}, append([]string{\"key\", \"group\", \"name\", \"type\"}, extraLabels...))\n\treg.MustRegister(resultDomainExpirationSeconds)\n\n\tresultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"results_endpoint_success\",\n\t\tHelp:      \"Displays whether or not the endpoint was a success\",\n\t}, append([]string{\"key\", \"group\", \"name\", \"type\"}, extraLabels...))\n\treg.MustRegister(resultEndpointSuccess)\n\n\t// Suite metrics\n\tsuiteResultTotal = prometheus.NewCounterVec(prometheus.CounterOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"suite_results_total\",\n\t\tHelp:      \"Total number of suite executions\",\n\t}, append([]string{\"key\", \"group\", \"name\", \"success\"}, extraLabels...))\n\treg.MustRegister(suiteResultTotal)\n\n\tsuiteResultDurationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"suite_results_duration_seconds\",\n\t\tHelp:      \"Duration of suite execution in seconds\",\n\t}, append([]string{\"key\", \"group\", \"name\"}, extraLabels...))\n\treg.MustRegister(suiteResultDurationSeconds)\n\n\tsuiteResultSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{\n\t\tNamespace: namespace,\n\t\tName:      \"suite_results_success\",\n\t\tHelp:      \"Whether the suite execution was successful (1) or not (0)\",\n\t}, append([]string{\"key\", \"group\", \"name\"}, extraLabels...))\n\treg.MustRegister(suiteResultSuccess)\n\n\t// Mark as initialized\n\tmetricsInitialized = true\n}\n\n// PublishMetricsForEndpoint publishes metrics for the given endpoint and its result.\n// These metrics will be exposed at /metrics if the metrics are enabled\nfunc PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, extraLabels []string) {\n\tvar labelValues []string\n\tfor _, label := range extraLabels {\n\t\tif value, ok := ep.ExtraLabels[label]; ok {\n\t\t\tlabelValues = append(labelValues, value)\n\t\t} else {\n\t\t\tlabelValues = append(labelValues, \"\")\n\t\t}\n\t}\n\tendpointType := ep.Type()\n\tresultTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)}, labelValues...)...).Inc()\n\tresultDurationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.Duration.Seconds())\n\tif result.Connected {\n\t\tresultConnectedTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Inc()\n\t}\n\tif result.DNSRCode != \"\" {\n\t\tresultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), result.DNSRCode}, labelValues...)...).Inc()\n\t}\n\tif result.HTTPStatus != 0 {\n\t\tresultCodeTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)}, labelValues...)...).Inc()\n\t}\n\tif result.CertificateExpiration != 0 {\n\t\tresultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())\n\t}\n\tif result.DomainExpiration != 0 {\n\t\tresultDomainExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.DomainExpiration.Seconds())\n\t}\n\tif result.Success {\n\t\tresultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)\n\t} else {\n\t\tresultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(0)\n\t}\n}\n\n// PublishMetricsForSuite publishes metrics for the given suite and its result.\n// These metrics will be exposed at /metrics if the metrics are enabled\nfunc PublishMetricsForSuite(s *suite.Suite, result *suite.Result, extraLabels []string) {\n\tif !metricsInitialized {\n\t\treturn\n\t}\n\tvar labelValues []string\n\t// For now, suites don't have ExtraLabels, so we'll use empty values\n\t// This maintains consistency with endpoint metrics structure\n\tfor range extraLabels {\n\t\tlabelValues = append(labelValues, \"\")\n\t}\n\t// Publish suite execution counter\n\tsuiteResultTotal.WithLabelValues(\n\t\tappend([]string{s.Key(), s.Group, s.Name, strconv.FormatBool(result.Success)}, labelValues...)...,\n\t).Inc()\n\t// Publish suite duration\n\tsuiteResultDurationSeconds.WithLabelValues(\n\t\tappend([]string{s.Key(), s.Group, s.Name}, labelValues...)...,\n\t).Set(result.Duration.Seconds())\n\t// Publish suite success status\n\tif result.Success {\n\t\tsuiteResultSuccess.WithLabelValues(\n\t\t\tappend([]string{s.Key(), s.Group, s.Name}, labelValues...)...,\n\t\t).Set(1)\n\t} else {\n\t\tsuiteResultSuccess.WithLabelValues(\n\t\t\tappend([]string{s.Key(), s.Group, s.Name}, labelValues...)...,\n\t\t).Set(0)\n\t}\n}\n"
  },
  {
    "path": "metrics/metrics_test.go",
    "content": "package metrics\n\nimport (\n\t\"bytes\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint/dns\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/testutil\"\n)\n\n// TestInitializePrometheusMetrics tests metrics initialization with extraLabels.\n// Note: Because of the global Prometheus registry, this test can only safely verify one label set per process.\n// If the function is called with a different set of labels for the same metric, a panic will occur.\nfunc TestInitializePrometheusMetrics(t *testing.T) {\n\tcfgWithExtras := &config.Config{\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName:  \"TestEP\",\n\t\t\t\tGroup: \"G\",\n\t\t\t\tURL:   \"http://x/\",\n\t\t\t\tExtraLabels: map[string]string{\n\t\t\t\t\t\"foo\":   \"foo-val\",\n\t\t\t\t\t\"hello\": \"world-val\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\treg := prometheus.NewRegistry()\n\tInitializePrometheusMetrics(cfgWithExtras, reg)\n\t// Metrics variables should be non-nil\n\tif resultTotal == nil {\n\t\tt.Error(\"resultTotal metric not initialized\")\n\t}\n\tif resultDurationSeconds == nil {\n\t\tt.Error(\"resultDurationSeconds metric not initialized\")\n\t}\n\tif resultConnectedTotal == nil {\n\t\tt.Error(\"resultConnectedTotal metric not initialized\")\n\t}\n\tif resultCodeTotal == nil {\n\t\tt.Error(\"resultCodeTotal metric not initialized\")\n\t}\n\tif resultCertificateExpirationSeconds == nil {\n\t\tt.Error(\"resultCertificateExpirationSeconds metric not initialized\")\n\t}\n\tif resultDomainExpirationSeconds == nil {\n\t\tt.Error(\"resultDomainExpirationSeconds metric not initialized\")\n\t}\n\tif resultEndpointSuccess == nil {\n\t\tt.Error(\"resultEndpointSuccess metric not initialized\")\n\t}\n\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tt.Errorf(\"resultTotal.WithLabelValues panicked: %v\", r)\n\t\t}\n\t}()\n\t_ = resultTotal.WithLabelValues(\"k\", \"g\", \"n\", \"ty\", \"true\", \"fval\", \"hval\")\n}\n\n// TestPublishMetricsForEndpoint_withExtraLabels ensures extraLabels are included in the exported metrics.\nfunc TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) {\n\t// Only test one label set per process due to Prometheus registry limits.\n\treg := prometheus.NewRegistry()\n\tcfg := &config.Config{\n\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tName: \"ep-extra\",\n\t\t\t\tURL:  \"https://sample.com\",\n\t\t\t\tExtraLabels: map[string]string{\n\t\t\t\t\t\"foo\": \"my-foo\",\n\t\t\t\t\t\"bar\": \"my-bar\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tInitializePrometheusMetrics(cfg, reg)\n\n\tep := &endpoint.Endpoint{\n\t\tName:  \"ep-extra\",\n\t\tGroup: \"g1\",\n\t\tURL:   \"https://sample.com\",\n\t\tExtraLabels: map[string]string{\n\t\t\t\"foo\": \"my-foo\",\n\t\t\t\"bar\": \"my-bar\",\n\t\t},\n\t}\n\tresult := &endpoint.Result{\n\t\tHTTPStatus: 200,\n\t\tConnected:  true,\n\t\tDuration:   2340 * time.Millisecond,\n\t\tSuccess:    true,\n\t}\n\t// Get labels in sorted order as per GetUniqueExtraMetricLabels\n\textraLabels := cfg.GetUniqueExtraMetricLabels()\n\tPublishMetricsForEndpoint(ep, result, extraLabels)\n\n\texpected := `\n# HELP gatus_results_total Number of results per endpoint\n# TYPE gatus_results_total counter\ngatus_results_total{bar=\"my-bar\",foo=\"my-foo\",group=\"g1\",key=\"g1_ep-extra\",name=\"ep-extra\",success=\"true\",type=\"HTTP\"} 1\n`\n\terr := testutil.GatherAndCompare(reg, bytes.NewBufferString(expected), \"gatus_results_total\")\n\tif err != nil {\n\t\tt.Error(\"metrics export does not include extraLabels as expected:\", err)\n\t}\n}\n\nfunc TestPublishMetricsForEndpoint(t *testing.T) {\n\treg := prometheus.NewRegistry()\n\tInitializePrometheusMetrics(&config.Config{}, reg)\n\n\thttpEndpoint := &endpoint.Endpoint{Name: \"http-ep-name\", Group: \"http-ep-group\", URL: \"https://example.org\"}\n\tPublishMetricsForEndpoint(httpEndpoint, &endpoint.Result{\n\t\tHTTPStatus: 200,\n\t\tConnected:  true,\n\t\tDuration:   123 * time.Millisecond,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{Condition: \"[STATUS] == 200\", Success: true},\n\t\t\t{Condition: \"[CERTIFICATE_EXPIRATION] > 48h\", Success: true},\n\t\t\t{Condition: \"[DOMAIN_EXPIRATION] > 24h\", Success: true},\n\t\t},\n\t\tSuccess:               true,\n\t\tCertificateExpiration: 49 * time.Hour,\n\t\tDomainExpiration:      25 * time.Hour,\n\t}, []string{})\n\terr := testutil.GatherAndCompare(reg, bytes.NewBufferString(`\n# HELP gatus_results_code_total Total number of results by code\n# TYPE gatus_results_code_total counter\ngatus_results_code_total{code=\"200\",group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 1\n# HELP gatus_results_connected_total Total number of results in which a connection was successfully established\n# TYPE gatus_results_connected_total counter\ngatus_results_connected_total{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 1\n# HELP gatus_results_duration_seconds Duration of the request in seconds\n# TYPE gatus_results_duration_seconds gauge\ngatus_results_duration_seconds{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 0.123\n# HELP gatus_results_total Number of results per endpoint\n# TYPE gatus_results_total counter\ngatus_results_total{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",success=\"true\",type=\"HTTP\"} 1\n# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires\n# TYPE gatus_results_certificate_expiration_seconds gauge\ngatus_results_certificate_expiration_seconds{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 176400\n# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires\n# TYPE gatus_results_domain_expiration_seconds gauge\ngatus_results_domain_expiration_seconds{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 90000\n# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success\n# TYPE gatus_results_endpoint_success gauge\ngatus_results_endpoint_success{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 1\n`), \"gatus_results_code_total\", \"gatus_results_connected_total\", \"gatus_results_duration_seconds\", \"gatus_results_total\", \"gatus_results_certificate_expiration_seconds\", \"gatus_results_endpoint_success\")\n\tif err != nil {\n\t\tt.Errorf(\"Expected no errors but got: %v\", err)\n\t}\n\tPublishMetricsForEndpoint(httpEndpoint, &endpoint.Result{\n\t\tHTTPStatus: 200,\n\t\tConnected:  true,\n\t\tDuration:   125 * time.Millisecond,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{Condition: \"[STATUS] == 200\", Success: true},\n\t\t\t{Condition: \"[CERTIFICATE_EXPIRATION] > 47h\", Success: false},\n\t\t\t{Condition: \"[DOMAIN_EXPIRATION] > 24h\", Success: true},\n\t\t},\n\t\tSuccess:               false,\n\t\tCertificateExpiration: 47 * time.Hour,\n\t\tDomainExpiration:      24 * time.Hour,\n\t}, []string{})\n\terr = testutil.GatherAndCompare(reg, bytes.NewBufferString(`\n# HELP gatus_results_code_total Total number of results by code\n# TYPE gatus_results_code_total counter\ngatus_results_code_total{code=\"200\",group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 2\n# HELP gatus_results_connected_total Total number of results in which a connection was successfully established\n# TYPE gatus_results_connected_total counter\ngatus_results_connected_total{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 2\n# HELP gatus_results_duration_seconds Duration of the request in seconds\n# TYPE gatus_results_duration_seconds gauge\ngatus_results_duration_seconds{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 0.125\n# HELP gatus_results_total Number of results per endpoint\n# TYPE gatus_results_total counter\ngatus_results_total{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",success=\"false\",type=\"HTTP\"} 1\ngatus_results_total{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",success=\"true\",type=\"HTTP\"} 1\n# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires\n# TYPE gatus_results_certificate_expiration_seconds gauge\ngatus_results_certificate_expiration_seconds{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 169200\n# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires\n# TYPE gatus_results_domain_expiration_seconds gauge\ngatus_results_domain_expiration_seconds{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 86400\n# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success\n# TYPE gatus_results_endpoint_success gauge\ngatus_results_endpoint_success{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 0\n`), \"gatus_results_code_total\", \"gatus_results_connected_total\", \"gatus_results_duration_seconds\", \"gatus_results_total\", \"gatus_results_certificate_expiration_seconds\", \"gatus_results_endpoint_success\")\n\tif err != nil {\n\t\tt.Errorf(\"Expected no errors but got: %v\", err)\n\t}\n\tdnsEndpoint := &endpoint.Endpoint{\n\t\tName: \"dns-ep-name\", Group: \"dns-ep-group\", URL: \"8.8.8.8\", DNSConfig: &dns.Config{\n\t\t\tQueryType: \"A\",\n\t\t\tQueryName: \"example.com.\",\n\t\t},\n\t}\n\tPublishMetricsForEndpoint(dnsEndpoint, &endpoint.Result{\n\t\tDNSRCode:  \"NOERROR\",\n\t\tConnected: true,\n\t\tDuration:  50 * time.Millisecond,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{Condition: \"[DNS_RCODE] == NOERROR\", Success: true},\n\t\t},\n\t\tSuccess: true,\n\t}, []string{})\n\terr = testutil.GatherAndCompare(reg, bytes.NewBufferString(`\n# HELP gatus_results_code_total Total number of results by code\n# TYPE gatus_results_code_total counter\ngatus_results_code_total{code=\"200\",group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 2\ngatus_results_code_total{code=\"NOERROR\",group=\"dns-ep-group\",key=\"dns-ep-group_dns-ep-name\",name=\"dns-ep-name\",type=\"DNS\"} 1\n# HELP gatus_results_connected_total Total number of results in which a connection was successfully established\n# TYPE gatus_results_connected_total counter\ngatus_results_connected_total{group=\"dns-ep-group\",key=\"dns-ep-group_dns-ep-name\",name=\"dns-ep-name\",type=\"DNS\"} 1\ngatus_results_connected_total{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 2\n# HELP gatus_results_duration_seconds Duration of the request in seconds\n# TYPE gatus_results_duration_seconds gauge\ngatus_results_duration_seconds{group=\"dns-ep-group\",key=\"dns-ep-group_dns-ep-name\",name=\"dns-ep-name\",type=\"DNS\"} 0.05\ngatus_results_duration_seconds{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 0.125\n# HELP gatus_results_total Number of results per endpoint\n# TYPE gatus_results_total counter\ngatus_results_total{group=\"dns-ep-group\",key=\"dns-ep-group_dns-ep-name\",name=\"dns-ep-name\",success=\"true\",type=\"DNS\"} 1\ngatus_results_total{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",success=\"false\",type=\"HTTP\"} 1\ngatus_results_total{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",success=\"true\",type=\"HTTP\"} 1\n# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires\n# TYPE gatus_results_certificate_expiration_seconds gauge\ngatus_results_certificate_expiration_seconds{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 169200\n# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success\n# TYPE gatus_results_endpoint_success gauge\ngatus_results_endpoint_success{group=\"dns-ep-group\",key=\"dns-ep-group_dns-ep-name\",name=\"dns-ep-name\",type=\"DNS\"} 1\ngatus_results_endpoint_success{group=\"http-ep-group\",key=\"http-ep-group_http-ep-name\",name=\"http-ep-name\",type=\"HTTP\"} 0\n`), \"gatus_results_code_total\", \"gatus_results_connected_total\", \"gatus_results_duration_seconds\", \"gatus_results_total\", \"gatus_results_certificate_expiration_seconds\", \"gatus_results_endpoint_success\")\n\tif err != nil {\n\t\tt.Errorf(\"Expected no errors but got: %v\", err)\n\t}\n}\n\nfunc TestPublishMetricsForSuite(t *testing.T) {\n\treg := prometheus.NewRegistry()\n\tInitializePrometheusMetrics(&config.Config{}, reg)\n\n\ttestSuite := &suite.Suite{\n\t\tName:  \"test-suite\",\n\t\tGroup: \"test-group\",\n\t}\n\t// Test successful suite execution\n\tsuccessResult := &suite.Result{\n\t\tSuccess:  true,\n\t\tDuration: 5 * time.Second,\n\t\tName:     \"test-suite\",\n\t\tGroup:    \"test-group\",\n\t}\n\tPublishMetricsForSuite(testSuite, successResult, []string{})\n\n\terr := testutil.GatherAndCompare(reg, bytes.NewBufferString(`\n# HELP gatus_suite_results_duration_seconds Duration of suite execution in seconds\n# TYPE gatus_suite_results_duration_seconds gauge\ngatus_suite_results_duration_seconds{group=\"test-group\",key=\"test-group_test-suite\",name=\"test-suite\"} 5\n# HELP gatus_suite_results_success Whether the suite execution was successful (1) or not (0)\n# TYPE gatus_suite_results_success gauge\ngatus_suite_results_success{group=\"test-group\",key=\"test-group_test-suite\",name=\"test-suite\"} 1\n# HELP gatus_suite_results_total Total number of suite executions\n# TYPE gatus_suite_results_total counter\ngatus_suite_results_total{group=\"test-group\",key=\"test-group_test-suite\",name=\"test-suite\",success=\"true\"} 1\n`), \"gatus_suite_results_duration_seconds\", \"gatus_suite_results_success\", \"gatus_suite_results_total\")\n\tif err != nil {\n\t\tt.Errorf(\"Expected no errors but got: %v\", err)\n\t}\n\n\t// Test failed suite execution\n\tfailureResult := &suite.Result{\n\t\tSuccess:  false,\n\t\tDuration: 10 * time.Second,\n\t\tName:     \"test-suite\",\n\t\tGroup:    \"test-group\",\n\t}\n\tPublishMetricsForSuite(testSuite, failureResult, []string{})\n\n\terr = testutil.GatherAndCompare(reg, bytes.NewBufferString(`\n# HELP gatus_suite_results_duration_seconds Duration of suite execution in seconds\n# TYPE gatus_suite_results_duration_seconds gauge\ngatus_suite_results_duration_seconds{group=\"test-group\",key=\"test-group_test-suite\",name=\"test-suite\"} 10\n# HELP gatus_suite_results_success Whether the suite execution was successful (1) or not (0)\n# TYPE gatus_suite_results_success gauge\ngatus_suite_results_success{group=\"test-group\",key=\"test-group_test-suite\",name=\"test-suite\"} 0\n# HELP gatus_suite_results_total Total number of suite executions\n# TYPE gatus_suite_results_total counter\ngatus_suite_results_total{group=\"test-group\",key=\"test-group_test-suite\",name=\"test-suite\",success=\"false\"} 1\ngatus_suite_results_total{group=\"test-group\",key=\"test-group_test-suite\",name=\"test-suite\",success=\"true\"} 1\n`), \"gatus_suite_results_duration_seconds\", \"gatus_suite_results_success\", \"gatus_suite_results_total\")\n\tif err != nil {\n\t\tt.Errorf(\"Expected no errors but got: %v\", err)\n\t}\n}\n\nfunc TestPublishMetricsForSuite_NoGroup(t *testing.T) {\n\treg := prometheus.NewRegistry()\n\tInitializePrometheusMetrics(&config.Config{}, reg)\n\n\ttestSuite := &suite.Suite{\n\t\tName:  \"no-group-suite\",\n\t\tGroup: \"\",\n\t}\n\tresult := &suite.Result{\n\t\tSuccess:  true,\n\t\tDuration: 3 * time.Second,\n\t\tName:     \"no-group-suite\",\n\t\tGroup:    \"\",\n\t}\n\tPublishMetricsForSuite(testSuite, result, []string{})\n\n\terr := testutil.GatherAndCompare(reg, bytes.NewBufferString(`\n# HELP gatus_suite_results_duration_seconds Duration of suite execution in seconds\n# TYPE gatus_suite_results_duration_seconds gauge\ngatus_suite_results_duration_seconds{group=\"\",key=\"_no-group-suite\",name=\"no-group-suite\"} 3\n# HELP gatus_suite_results_success Whether the suite execution was successful (1) or not (0)\n# TYPE gatus_suite_results_success gauge\ngatus_suite_results_success{group=\"\",key=\"_no-group-suite\",name=\"no-group-suite\"} 1\n# HELP gatus_suite_results_total Total number of suite executions\n# TYPE gatus_suite_results_total counter\ngatus_suite_results_total{group=\"\",key=\"_no-group-suite\",name=\"no-group-suite\",success=\"true\"} 1\n`), \"gatus_suite_results_duration_seconds\", \"gatus_suite_results_success\", \"gatus_suite_results_total\")\n\tif err != nil {\n\t\tt.Errorf(\"Expected no errors but got: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "pattern/pattern.go",
    "content": "package pattern\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n// Match checks whether a string matches a pattern\nfunc Match(pattern, s string) bool {\n\tif pattern == \"*\" {\n\t\treturn true\n\t}\n\t// Separators found in the string break filepath.Match, so we'll remove all of them.\n\t// This has a pretty significant impact on performance when there are separators in\n\t// the strings, but at least it doesn't break filepath.Match.\n\ts = strings.ReplaceAll(s, string(filepath.Separator), \"\")\n\tpattern = strings.ReplaceAll(pattern, string(filepath.Separator), \"\")\n\tmatched, _ := filepath.Match(pattern, s)\n\treturn matched\n}\n"
  },
  {
    "path": "pattern/pattern_bench_test.go",
    "content": "package pattern\n\nimport \"testing\"\n\nfunc BenchmarkMatch(b *testing.B) {\n\tfor n := 0; n < b.N; n++ {\n\t\tif !Match(\"*ing*\", \"livingroom\") {\n\t\t\tb.Error(\"should've matched\")\n\t\t}\n\t}\n\tb.ReportAllocs()\n}\n\nfunc BenchmarkMatchWithBackslash(b *testing.B) {\n\tfor n := 0; n < b.N; n++ {\n\t\tif !Match(\"*ing*\", \"living\\\\room\") {\n\t\t\tb.Error(\"should've matched\")\n\t\t}\n\t}\n\tb.ReportAllocs()\n}\n"
  },
  {
    "path": "pattern/pattern_test.go",
    "content": "package pattern\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestMatch(t *testing.T) {\n\ttestMatch(t, \"*\", \"livingroom_123\", true)\n\ttestMatch(t, \"**\", \"livingroom_123\", true)\n\ttestMatch(t, \"living*\", \"livingroom_123\", true)\n\ttestMatch(t, \"*living*\", \"livingroom_123\", true)\n\ttestMatch(t, \"*123\", \"livingroom_123\", true)\n\ttestMatch(t, \"*_*\", \"livingroom_123\", true)\n\ttestMatch(t, \"living*_*3\", \"livingroom_123\", true)\n\ttestMatch(t, \"living*room_*3\", \"livingroom_123\", true)\n\ttestMatch(t, \"living*room_*3\", \"livingroom_123\", true)\n\ttestMatch(t, \"*vin*om*2*\", \"livingroom_123\", true)\n\ttestMatch(t, \"livingroom_123\", \"livingroom_123\", true)\n\ttestMatch(t, \"*livingroom_123*\", \"livingroom_123\", true)\n\ttestMatch(t, \"*test*\", \"\\\\test\", true)\n\ttestMatch(t, \"livingroom\", \"livingroom_123\", false)\n\ttestMatch(t, \"livingroom123\", \"livingroom_123\", false)\n\ttestMatch(t, \"what\", \"livingroom_123\", false)\n\ttestMatch(t, \"*what*\", \"livingroom_123\", false)\n\ttestMatch(t, \"*.*\", \"livingroom_123\", false)\n\ttestMatch(t, \"room*123\", \"livingroom_123\", false)\n}\n\nfunc testMatch(t *testing.T, pattern, key string, expectedToMatch bool) {\n\tt.Run(fmt.Sprintf(\"pattern '%s' from '%s'\", pattern, key), func(t *testing.T) {\n\t\tmatched := Match(pattern, key)\n\t\tif expectedToMatch {\n\t\t\tif !matched {\n\t\t\t\tt.Errorf(\"%s should've matched pattern '%s'\", key, pattern)\n\t\t\t}\n\t\t} else {\n\t\t\tif matched {\n\t\t\t\tt.Errorf(\"%s shouldn't have matched pattern '%s'\", key, pattern)\n\t\t\t}\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "security/basic.go",
    "content": "package security\n\n// BasicConfig is the configuration for Basic authentication\ntype BasicConfig struct {\n\t// Username is the name which will need to be used for a successful authentication\n\tUsername string `yaml:\"username\"`\n\n\t// PasswordBcryptHashBase64Encoded is the base64 encoded string of the Bcrypt hash of the password to use to\n\t// authenticate using basic auth.\n\tPasswordBcryptHashBase64Encoded string `yaml:\"password-bcrypt-base64\"`\n}\n\n// isValid returns whether the basic security configuration is valid or not\nfunc (c *BasicConfig) isValid() bool {\n\treturn len(c.Username) > 0 && len(c.PasswordBcryptHashBase64Encoded) > 0\n}\n"
  },
  {
    "path": "security/basic_test.go",
    "content": "package security\n\nimport \"testing\"\n\nfunc TestBasicConfig_IsValidUsingBcrypt(t *testing.T) {\n\tbasicConfig := &BasicConfig{\n\t\tUsername:                        \"admin\",\n\t\tPasswordBcryptHashBase64Encoded: \"JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT\",\n\t}\n\tif !basicConfig.isValid() {\n\t\tt.Error(\"basicConfig should've been valid\")\n\t}\n}\n\nfunc TestBasicConfig_IsValidWhenPasswordIsInvalidUsingBcrypt(t *testing.T) {\n\tbasicConfig := &BasicConfig{\n\t\tUsername:                        \"admin\",\n\t\tPasswordBcryptHashBase64Encoded: \"\",\n\t}\n\tif basicConfig.isValid() {\n\t\tt.Error(\"basicConfig shouldn't have been valid\")\n\t}\n}\n"
  },
  {
    "path": "security/config.go",
    "content": "package security\n\nimport (\n\t\"encoding/base64\"\n\t\"net/http\"\n\n\tg8 \"github.com/TwiN/g8/v2\"\n\t\"github.com/TwiN/logr\"\n\t\"github.com/gofiber/fiber/v2\"\n\t\"github.com/gofiber/fiber/v2/middleware/adaptor\"\n\t\"github.com/gofiber/fiber/v2/middleware/basicauth\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\nconst (\n\tcookieNameState   = \"gatus_state\"\n\tcookieNameNonce   = \"gatus_nonce\"\n\tcookieNameSession = \"gatus_session\"\n)\n\n// Config is the security configuration for Gatus\ntype Config struct {\n\tBasic *BasicConfig `yaml:\"basic,omitempty\"`\n\tOIDC  *OIDCConfig  `yaml:\"oidc,omitempty\"`\n\n\tgate *g8.Gate\n}\n\n// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.\nfunc (c *Config) ValidateAndSetDefaults() bool {\n\treturn (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())\n}\n\n// RegisterHandlers registers all handlers required based on the security configuration\nfunc (c *Config) RegisterHandlers(router fiber.Router) error {\n\tif c.OIDC != nil {\n\t\tif err := c.OIDC.initialize(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\trouter.All(\"/oidc/login\", c.OIDC.loginHandler)\n\t\trouter.All(\"/authorization-code/callback\", adaptor.HTTPHandlerFunc(c.OIDC.callbackHandler))\n\t}\n\treturn nil\n}\n\n// ApplySecurityMiddleware applies an authentication middleware to the router passed.\n// The router passed should be a sub-router in charge of handlers that require authentication.\nfunc (c *Config) ApplySecurityMiddleware(router fiber.Router) error {\n\tif c.OIDC != nil {\n\t\t// We're going to use g8 for session handling\n\t\tclientProvider := g8.NewClientProvider(func(token string) *g8.Client {\n\t\t\tif _, exists := sessions.Get(token); exists {\n\t\t\t\treturn g8.NewClient(token)\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t\tcustomTokenExtractorFunc := func(request *http.Request) string {\n\t\t\tsessionCookie, err := request.Cookie(cookieNameSession)\n\t\t\tif err != nil {\n\t\t\t\treturn \"\"\n\t\t\t}\n\t\t\treturn sessionCookie.Value\n\t\t}\n\t\t// TODO: g8: Add a way to update cookie after? would need the writer\n\t\tauthorizationService := g8.NewAuthorizationService().WithClientProvider(clientProvider)\n\t\tc.gate = g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)\n\t\trouter.Use(adaptor.HTTPMiddleware(c.gate.Protect))\n\t} else if c.Basic != nil {\n\t\tvar decodedBcryptHash []byte\n\t\tif len(c.Basic.PasswordBcryptHashBase64Encoded) > 0 {\n\t\t\tvar err error\n\t\t\tdecodedBcryptHash, err = base64.URLEncoding.DecodeString(c.Basic.PasswordBcryptHashBase64Encoded)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\trouter.Use(basicauth.New(basicauth.Config{\n\t\t\tAuthorizer: func(username, password string) bool {\n\t\t\t\tif len(c.Basic.PasswordBcryptHashBase64Encoded) > 0 {\n\t\t\t\t\tif username != c.Basic.Username || bcrypt.CompareHashAndPassword(decodedBcryptHash, []byte(password)) != nil {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t},\n\t\t\tUnauthorized: func(ctx *fiber.Ctx) error {\n\t\t\t\tctx.Set(\"WWW-Authenticate\", \"Basic\")\n\t\t\t\treturn ctx.Status(401).SendString(\"Unauthorized\")\n\t\t\t},\n\t\t}))\n\t}\n\treturn nil\n}\n\n// IsAuthenticated checks whether the user is authenticated\n// If the Config does not warrant authentication, it will always return true.\nfunc (c *Config) IsAuthenticated(ctx *fiber.Ctx) bool {\n\tif c.gate != nil {\n\t\t// TODO: Update g8 to support fasthttp natively? (see g8's fasthttp branch)\n\t\trequest, err := adaptor.ConvertRequest(ctx, false)\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[security.IsAuthenticated] Unexpected error converting request: %v\", err)\n\t\t\treturn false\n\t\t}\n\t\ttoken := c.gate.ExtractTokenFromRequest(request)\n\t\t_, hasSession := sessions.Get(token)\n\t\treturn hasSession\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "security/config_test.go",
    "content": "package security\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gofiber/fiber/v2\"\n\t\"golang.org/x/oauth2\"\n)\n\nfunc TestConfig_ValidateAndSetDefaults(t *testing.T) {\n\tc := &Config{\n\t\tBasic: nil,\n\t\tOIDC:  nil,\n\t}\n\tif c.ValidateAndSetDefaults() {\n\t\tt.Error(\"expected empty config to be valid\")\n\t}\n}\n\nfunc TestConfig_ApplySecurityMiddleware(t *testing.T) {\n\t///////////\n\t// BASIC //\n\t///////////\n\tt.Run(\"basic\", func(t *testing.T) {\n\t\t// Bcrypt\n\t\tc := &Config{Basic: &BasicConfig{\n\t\t\tUsername:                        \"john.doe\",\n\t\t\tPasswordBcryptHashBase64Encoded: \"JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT\",\n\t\t}}\n\t\tapp := fiber.New()\n\t\tif err := c.ApplySecurityMiddleware(app); err != nil {\n\t\t\tt.Error(\"expected no error, got\", err)\n\t\t}\n\t\tapp.Get(\"/test\", func(c *fiber.Ctx) error {\n\t\t\treturn c.SendStatus(200)\n\t\t})\n\t\t// Try to access the route without basic auth\n\t\trequest := httptest.NewRequest(\"GET\", \"/test\", http.NoBody)\n\t\tresponse, err := app.Test(request)\n\t\tif err != nil {\n\t\t\tt.Fatal(\"expected no error, got\", err)\n\t\t}\n\t\tif response.StatusCode != 401 {\n\t\t\tt.Error(\"expected code to be 401, but was\", response.StatusCode)\n\t\t}\n\t\t// Try again, but with basic auth\n\t\trequest = httptest.NewRequest(\"GET\", \"/test\", http.NoBody)\n\t\trequest.SetBasicAuth(\"john.doe\", \"hunter2\")\n\t\tresponse, err = app.Test(request)\n\t\tif err != nil {\n\t\t\tt.Fatal(\"expected no error, got\", err)\n\t\t}\n\t\tif response.StatusCode != 200 {\n\t\t\tt.Error(\"expected code to be 200, but was\", response.StatusCode)\n\t\t}\n\t})\n\t//////////\n\t// OIDC //\n\t//////////\n\tt.Run(\"oidc\", func(t *testing.T) {\n\t\tc := &Config{OIDC: &OIDCConfig{\n\t\t\tIssuerURL:       \"https://sso.gatus.io/\",\n\t\t\tRedirectURL:     \"http://localhost:80/authorization-code/callback\",\n\t\t\tScopes:          []string{\"openid\"},\n\t\t\tAllowedSubjects: []string{\"user1@example.com\"},\n\t\t\tSessionTTL:      DefaultOIDCSessionTTL,\n\t\t\toauth2Config:    oauth2.Config{},\n\t\t\tverifier:        nil,\n\t\t}}\n\t\tapp := fiber.New()\n\t\tif err := c.ApplySecurityMiddleware(app); err != nil {\n\t\t\tt.Error(\"expected no error, got\", err)\n\t\t}\n\t\tapp.Get(\"/test\", func(c *fiber.Ctx) error {\n\t\t\treturn c.SendStatus(200)\n\t\t})\n\t\t// Try without any session cookie\n\t\trequest := httptest.NewRequest(\"GET\", \"/test\", http.NoBody)\n\t\tresponse, err := app.Test(request)\n\t\tif err != nil {\n\t\t\tt.Fatal(\"expected no error, got\", err)\n\t\t}\n\t\tif response.StatusCode != 401 {\n\t\t\tt.Error(\"expected code to be 401, but was\", response.StatusCode)\n\t\t}\n\t\t// Try with a session cookie\n\t\trequest = httptest.NewRequest(\"GET\", \"/test\", http.NoBody)\n\t\trequest.AddCookie(&http.Cookie{Name: \"session\", Value: \"123\"})\n\t\tresponse, err = app.Test(request)\n\t\tif err != nil {\n\t\t\tt.Fatal(\"expected no error, got\", err)\n\t\t}\n\t\tif response.StatusCode != 401 {\n\t\t\tt.Error(\"expected code to be 401, but was\", response.StatusCode)\n\t\t}\n\t})\n}\n\nfunc TestConfig_RegisterHandlers(t *testing.T) {\n\tc := &Config{}\n\tapp := fiber.New()\n\tc.RegisterHandlers(app)\n\t// Try to access the OIDC handler. This should fail, because the security config doesn't have OIDC\n\trequest := httptest.NewRequest(\"GET\", \"/oidc/login\", http.NoBody)\n\tresponse, err := app.Test(request)\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err)\n\t}\n\tif response.StatusCode != 404 {\n\t\tt.Error(\"expected code to be 404, but was\", response.StatusCode)\n\t}\n\t// Set an empty OIDC config. This should fail, because the IssuerURL is required.\n\tc.OIDC = &OIDCConfig{}\n\tif err := c.RegisterHandlers(app); err == nil {\n\t\tt.Fatal(\"expected an error, but got none\")\n\t}\n\t// Set the OIDC config and try again\n\tc.OIDC = &OIDCConfig{\n\t\tIssuerURL:       \"https://sso.gatus.io/\",\n\t\tRedirectURL:     \"http://localhost:80/authorization-code/callback\",\n\t\tScopes:          []string{\"openid\"},\n\t\tAllowedSubjects: []string{\"user1@example.com\"},\n\t}\n\tif err := c.RegisterHandlers(app); err != nil {\n\t\tt.Fatal(\"expected no error, but got\", err)\n\t}\n\trequest = httptest.NewRequest(\"GET\", \"/oidc/login\", http.NoBody)\n\tresponse, err = app.Test(request)\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err)\n\t}\n\tif response.StatusCode != 302 {\n\t\tt.Error(\"expected code to be 302, but was\", response.StatusCode)\n\t}\n}\n"
  },
  {
    "path": "security/oidc.go",
    "content": "package security\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/logr\"\n\t\"github.com/coreos/go-oidc/v3/oidc\"\n\t\"github.com/gofiber/fiber/v2\"\n\t\"github.com/google/uuid\"\n\t\"golang.org/x/oauth2\"\n)\n\nconst (\n\tDefaultOIDCSessionTTL = 8 * time.Hour\n)\n\n// OIDCConfig is the configuration for OIDC authentication\ntype OIDCConfig struct {\n\tIssuerURL       string        `yaml:\"issuer-url\"`   // e.g. https://dev-12345678.okta.com\n\tRedirectURL     string        `yaml:\"redirect-url\"` // e.g. http://localhost:8080/authorization-code/callback\n\tClientID        string        `yaml:\"client-id\"`\n\tClientSecret    string        `yaml:\"client-secret\"`\n\tScopes          []string      `yaml:\"scopes\"`           // e.g. [\"openid\"]\n\tAllowedSubjects []string      `yaml:\"allowed-subjects\"` // e.g. [\"user1@example.com\"]. If empty, all subjects are allowed\n\tSessionTTL      time.Duration `yaml:\"session-ttl\"`      // e.g. 8h. Defaults to 8 hours\n\n\toauth2Config oauth2.Config\n\tverifier     *oidc.IDTokenVerifier\n}\n\n// ValidateAndSetDefaults returns whether the OIDC configuration is valid and sets default values.\nfunc (c *OIDCConfig) ValidateAndSetDefaults() bool {\n\tif c.SessionTTL <= 0 {\n\t\tc.SessionTTL = DefaultOIDCSessionTTL\n\t}\n\treturn 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\n}\n\nfunc (c *OIDCConfig) initialize() error {\n\tprovider, err := oidc.NewProvider(context.Background(), c.IssuerURL)\n\tif err != nil {\n\t\treturn err\n\t}\n\tc.verifier = provider.Verifier(&oidc.Config{ClientID: c.ClientID})\n\t// Configure an OpenID Connect aware OAuth2 client.\n\tc.oauth2Config = oauth2.Config{\n\t\tClientID:     c.ClientID,\n\t\tClientSecret: c.ClientSecret,\n\t\tScopes:       c.Scopes,\n\t\tRedirectURL:  c.RedirectURL,\n\t\tEndpoint:     provider.Endpoint(),\n\t}\n\treturn nil\n}\n\nfunc (c *OIDCConfig) loginHandler(ctx *fiber.Ctx) error {\n\tstate, nonce := uuid.NewString(), uuid.NewString()\n\tctx.Cookie(&fiber.Cookie{\n\t\tName:     cookieNameState,\n\t\tValue:    state,\n\t\tPath:     \"/\",\n\t\tMaxAge:   int(time.Hour.Seconds()),\n\t\tSameSite: \"lax\",\n\t\tHTTPOnly: true,\n\t})\n\tctx.Cookie(&fiber.Cookie{\n\t\tName:     cookieNameNonce,\n\t\tValue:    nonce,\n\t\tPath:     \"/\",\n\t\tMaxAge:   int(time.Hour.Seconds()),\n\t\tSameSite: \"lax\",\n\t\tHTTPOnly: true,\n\t})\n\treturn ctx.Redirect(c.oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)\n}\n\nfunc (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { // TODO: Migrate to a native fiber handler\n\t// Check if there's an error\n\tif len(r.URL.Query().Get(\"error\")) > 0 {\n\t\thttp.Error(w, r.URL.Query().Get(\"error\")+\": \"+r.URL.Query().Get(\"error_description\"), http.StatusBadRequest)\n\t\treturn\n\t}\n\t// Ensure that the state has the expected value\n\tstate, err := r.Cookie(cookieNameState)\n\tif err != nil {\n\t\thttp.Error(w, \"state not found\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tif r.URL.Query().Get(\"state\") != state.Value {\n\t\thttp.Error(w, \"state did not match\", http.StatusBadRequest)\n\t\treturn\n\t}\n\t// Validate token\n\toauth2Token, err := c.oauth2Config.Exchange(r.Context(), r.URL.Query().Get(\"code\"))\n\tif err != nil {\n\t\thttp.Error(w, \"Error exchanging token: \"+err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\trawIDToken, ok := oauth2Token.Extra(\"id_token\").(string)\n\tif !ok {\n\t\thttp.Error(w, \"Missing 'id_token' in oauth2 token\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\tidToken, err := c.verifier.Verify(r.Context(), rawIDToken)\n\tif err != nil {\n\t\thttp.Error(w, \"Failed to verify id_token: \"+err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\t// Validate nonce\n\tnonce, err := r.Cookie(cookieNameNonce)\n\tif err != nil {\n\t\thttp.Error(w, \"nonce not found\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tif idToken.Nonce != nonce.Value {\n\t\thttp.Error(w, \"nonce did not match\", http.StatusBadRequest)\n\t\treturn\n\t}\n\tif len(c.AllowedSubjects) == 0 {\n\t\t// If there's no allowed subjects, all subjects are allowed.\n\t\tc.setSessionCookie(w, idToken)\n\t\thttp.Redirect(w, r, \"/\", http.StatusFound)\n\t\treturn\n\t}\n\tfor _, subject := range c.AllowedSubjects {\n\t\tif strings.ToLower(subject) == strings.ToLower(idToken.Subject) {\n\t\t\tc.setSessionCookie(w, idToken)\n\t\t\thttp.Redirect(w, r, \"/\", http.StatusFound)\n\t\t\treturn\n\t\t}\n\t}\n\tlogr.Debugf(\"[security.callbackHandler] Subject %s is not in the list of allowed subjects\", idToken.Subject)\n\thttp.Redirect(w, r, \"/?error=access_denied\", http.StatusFound)\n}\n\nfunc (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {\n\t// At this point, the user has been confirmed. All that's left to do is create a session.\n\tsessionID := uuid.NewString()\n\tsessions.SetWithTTL(sessionID, idToken.Subject, c.SessionTTL)\n\thttp.SetCookie(w, &http.Cookie{\n\t\tName:     cookieNameSession,\n\t\tValue:    sessionID,\n\t\tPath:     \"/\",\n\t\tMaxAge:   int(c.SessionTTL.Seconds()),\n\t\tSameSite: http.SameSiteStrictMode,\n\t})\n}\n"
  },
  {
    "path": "security/oidc_test.go",
    "content": "package security\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/coreos/go-oidc/v3/oidc\"\n)\n\nfunc TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) {\n\tc := &OIDCConfig{\n\t\tIssuerURL:       \"https://sso.gatus.io/\",\n\t\tRedirectURL:     \"http://localhost:80/authorization-code/callback\",\n\t\tClientID:        \"client-id\",\n\t\tClientSecret:    \"client-secret\",\n\t\tScopes:          []string{\"openid\"},\n\t\tAllowedSubjects: []string{\"user1@example.com\"},\n\t\tSessionTTL:      0, // Not set! ValidateAndSetDefaults should set it to DefaultOIDCSessionTTL\n\t}\n\tif !c.ValidateAndSetDefaults() {\n\t\tt.Error(\"OIDCConfig should be valid\")\n\t}\n\tif c.SessionTTL != DefaultOIDCSessionTTL {\n\t\tt.Error(\"expected SessionTTL to be set to DefaultOIDCSessionTTL\")\n\t}\n}\n\nfunc TestOIDCConfig_callbackHandler(t *testing.T) {\n\tc := &OIDCConfig{\n\t\tIssuerURL:       \"https://sso.gatus.io/\",\n\t\tRedirectURL:     \"http://localhost:80/authorization-code/callback\",\n\t\tClientID:        \"client-id\",\n\t\tClientSecret:    \"client-secret\",\n\t\tScopes:          []string{\"openid\"},\n\t\tAllowedSubjects: []string{\"user1@example.com\"},\n\t}\n\tif err := c.initialize(); err != nil {\n\t\tt.Fatal(\"expected no error, but got\", err)\n\t}\n\t// Try with no state cookie\n\trequest, _ := http.NewRequest(\"GET\", \"/authorization-code/callback\", nil)\n\tresponseRecorder := httptest.NewRecorder()\n\tc.callbackHandler(responseRecorder, request)\n\tif responseRecorder.Code != http.StatusBadRequest {\n\t\tt.Error(\"expected code to be 400, but was\", responseRecorder.Code)\n\t}\n\t// Try with state cookie\n\trequest, _ = http.NewRequest(\"GET\", \"/authorization-code/callback\", nil)\n\trequest.AddCookie(&http.Cookie{Name: cookieNameState, Value: \"fake-state\"})\n\tresponseRecorder = httptest.NewRecorder()\n\tc.callbackHandler(responseRecorder, request)\n\tif responseRecorder.Code != http.StatusBadRequest {\n\t\tt.Error(\"expected code to be 400, but was\", responseRecorder.Code)\n\t}\n\t// Try with state cookie and state query parameter\n\trequest, _ = http.NewRequest(\"GET\", \"/authorization-code/callback?state=fake-state\", nil)\n\trequest.AddCookie(&http.Cookie{Name: cookieNameState, Value: \"fake-state\"})\n\tresponseRecorder = httptest.NewRecorder()\n\tc.callbackHandler(responseRecorder, request)\n\t// Exchange should fail, so 500.\n\tif responseRecorder.Code != http.StatusInternalServerError {\n\t\tt.Error(\"expected code to be 500, but was\", responseRecorder.Code)\n\t}\n}\n\nfunc TestOIDCConfig_setSessionCookie(t *testing.T) {\n\tc := &OIDCConfig{}\n\tresponseRecorder := httptest.NewRecorder()\n\tc.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: \"test@example.com\"})\n\tif len(responseRecorder.Result().Cookies()) == 0 {\n\t\tt.Error(\"expected cookie to be set\")\n\t}\n}\n\nfunc TestOIDCConfig_setSessionCookieWithCustomTTL(t *testing.T) {\n\tcustomTTL := 30 * time.Minute\n\tc := &OIDCConfig{SessionTTL: customTTL}\n\tresponseRecorder := httptest.NewRecorder()\n\tc.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: \"test@example.com\"})\n\tcookies := responseRecorder.Result().Cookies()\n\tif len(cookies) == 0 {\n\t\tt.Error(\"expected cookie to be set\")\n\t}\n\tsessionCookie := cookies[0]\n\tif sessionCookie.MaxAge != int(customTTL.Seconds()) {\n\t\tt.Errorf(\"expected cookie MaxAge to be %d, but was %d\", int(customTTL.Seconds()), sessionCookie.MaxAge)\n\t}\n}\n"
  },
  {
    "path": "security/sessions.go",
    "content": "package security\n\nimport \"github.com/TwiN/gocache/v2\"\n\nvar sessions = gocache.NewCache().WithEvictionPolicy(gocache.LeastRecentlyUsed) // TODO: Move this to storage\n"
  },
  {
    "path": "storage/config.go",
    "content": "package storage\n\nimport (\n\t\"errors\"\n)\n\nconst (\n\tDefaultMaximumNumberOfResults = 100\n\tDefaultMaximumNumberOfEvents  = 50\n)\n\nvar (\n\tErrSQLStorageRequiresPath          = errors.New(\"sql storage requires a non-empty path to be defined\")\n\tErrMemoryStorageDoesNotSupportPath = errors.New(\"memory storage does not support persistence, use sqlite if you want persistence on file\")\n)\n\n// Config is the configuration for storage\ntype Config struct {\n\t// Path is the path used by the store to achieve persistence\n\t// If blank, persistence is disabled.\n\t// Note that not all Type support persistence\n\tPath string `yaml:\"path\"`\n\n\t// Type of store\n\t// If blank, uses the default in-memory store\n\tType Type `yaml:\"type\"`\n\n\t// Caching is whether to enable caching.\n\t// This is used to drastically decrease read latency by pre-emptively caching writes\n\t// as they happen, also known as the write-through caching strategy.\n\t// Does not apply if Config.Type is not TypePostgres or TypeSQLite.\n\tCaching bool `yaml:\"caching,omitempty\"`\n\n\t// MaximumNumberOfResults is the number of results each endpoint should be able to provide\n\tMaximumNumberOfResults int `yaml:\"maximum-number-of-results,omitempty\"`\n\n\t// MaximumNumberOfEvents is the number of events each endpoint should be able to provide\n\tMaximumNumberOfEvents int `yaml:\"maximum-number-of-events,omitempty\"`\n}\n\n// ValidateAndSetDefaults validates the configuration and sets the default values (if applicable)\nfunc (c *Config) ValidateAndSetDefaults() error {\n\tif c.Type == \"\" {\n\t\tc.Type = TypeMemory\n\t}\n\tif (c.Type == TypePostgres || c.Type == TypeSQLite) && len(c.Path) == 0 {\n\t\treturn ErrSQLStorageRequiresPath\n\t}\n\tif c.Type == TypeMemory && len(c.Path) > 0 {\n\t\treturn ErrMemoryStorageDoesNotSupportPath\n\t}\n\tif c.MaximumNumberOfResults <= 0 {\n\t\tc.MaximumNumberOfResults = DefaultMaximumNumberOfResults\n\t}\n\tif c.MaximumNumberOfEvents <= 0 {\n\t\tc.MaximumNumberOfEvents = DefaultMaximumNumberOfEvents\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "storage/store/common/errors.go",
    "content": "package common\n\nimport \"errors\"\n\nvar (\n\tErrEndpointNotFound = errors.New(\"endpoint not found\")               // When an endpoint does not exist in the store\n\tErrSuiteNotFound    = errors.New(\"suite not found\")                  // When a suite does not exist in the store\n\tErrInvalidTimeRange = errors.New(\"'from' cannot be older than 'to'\") // When an invalid time range is provided\n)\n"
  },
  {
    "path": "storage/store/common/paging/endpoint_status_params.go",
    "content": "package paging\n\n// EndpointStatusParams represents all parameters that can be used for paging purposes\ntype EndpointStatusParams struct {\n\tEventsPage      int // Number of the event page\n\tEventsPageSize  int // Size of the event page\n\tResultsPage     int // Number of the result page\n\tResultsPageSize int // Size of the result page\n}\n\n// NewEndpointStatusParams creates a new EndpointStatusParams\nfunc NewEndpointStatusParams() *EndpointStatusParams {\n\treturn &EndpointStatusParams{}\n}\n\n// WithEvents sets the values for EventsPage and EventsPageSize\nfunc (params *EndpointStatusParams) WithEvents(page, pageSize int) *EndpointStatusParams {\n\tparams.EventsPage = page\n\tparams.EventsPageSize = pageSize\n\treturn params\n}\n\n// WithResults sets the values for ResultsPage and ResultsPageSize\nfunc (params *EndpointStatusParams) WithResults(page, pageSize int) *EndpointStatusParams {\n\tparams.ResultsPage = page\n\tparams.ResultsPageSize = pageSize\n\treturn params\n}\n"
  },
  {
    "path": "storage/store/common/paging/endpoint_status_params_test.go",
    "content": "package paging\n\nimport \"testing\"\n\nfunc TestNewEndpointStatusParams(t *testing.T) {\n\ttype Scenario struct {\n\t\tName                    string\n\t\tParams                  *EndpointStatusParams\n\t\tExpectedEventsPage      int\n\t\tExpectedEventsPageSize  int\n\t\tExpectedResultsPage     int\n\t\tExpectedResultsPageSize int\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:                    \"empty-params\",\n\t\t\tParams:                  NewEndpointStatusParams(),\n\t\t\tExpectedEventsPage:      0,\n\t\t\tExpectedEventsPageSize:  0,\n\t\t\tExpectedResultsPage:     0,\n\t\t\tExpectedResultsPageSize: 0,\n\t\t},\n\t\t{\n\t\t\tName:                    \"with-events-page-2-size-7\",\n\t\t\tParams:                  NewEndpointStatusParams().WithEvents(2, 7),\n\t\t\tExpectedEventsPage:      2,\n\t\t\tExpectedEventsPageSize:  7,\n\t\t\tExpectedResultsPage:     0,\n\t\t\tExpectedResultsPageSize: 0,\n\t\t},\n\t\t{\n\t\t\tName:                    \"with-events-page-4-size-3-uptime\",\n\t\t\tParams:                  NewEndpointStatusParams().WithEvents(4, 3),\n\t\t\tExpectedEventsPage:      4,\n\t\t\tExpectedEventsPageSize:  3,\n\t\t\tExpectedResultsPage:     0,\n\t\t\tExpectedResultsPageSize: 0,\n\t\t},\n\t\t{\n\t\t\tName:                    \"with-results-page-1-size-20-uptime\",\n\t\t\tParams:                  NewEndpointStatusParams().WithResults(1, 20),\n\t\t\tExpectedEventsPage:      0,\n\t\t\tExpectedEventsPageSize:  0,\n\t\t\tExpectedResultsPage:     1,\n\t\t\tExpectedResultsPageSize: 20,\n\t\t},\n\t\t{\n\t\t\tName:                    \"with-results-page-2-size-10-events-page-3-size-50\",\n\t\t\tParams:                  NewEndpointStatusParams().WithResults(2, 10).WithEvents(3, 50),\n\t\t\tExpectedEventsPage:      3,\n\t\t\tExpectedEventsPageSize:  50,\n\t\t\tExpectedResultsPage:     2,\n\t\t\tExpectedResultsPageSize: 10,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tif scenario.Params.EventsPage != scenario.ExpectedEventsPage {\n\t\t\t\tt.Errorf(\"expected ExpectedEventsPage to be %d, was %d\", scenario.ExpectedEventsPageSize, scenario.Params.EventsPage)\n\t\t\t}\n\t\t\tif scenario.Params.EventsPageSize != scenario.ExpectedEventsPageSize {\n\t\t\t\tt.Errorf(\"expected EventsPageSize to be %d, was %d\", scenario.ExpectedEventsPageSize, scenario.Params.EventsPageSize)\n\t\t\t}\n\t\t\tif scenario.Params.ResultsPage != scenario.ExpectedResultsPage {\n\t\t\t\tt.Errorf(\"expected ResultsPage to be %d, was %d\", scenario.ExpectedResultsPage, scenario.Params.ResultsPage)\n\t\t\t}\n\t\t\tif scenario.Params.ResultsPageSize != scenario.ExpectedResultsPageSize {\n\t\t\t\tt.Errorf(\"expected ResultsPageSize to be %d, was %d\", scenario.ExpectedResultsPageSize, scenario.Params.ResultsPageSize)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "storage/store/common/paging/suite_status_params.go",
    "content": "package paging\n\n// SuiteStatusParams represents the parameters for suite status queries\ntype SuiteStatusParams struct {\n\tPage     int // Page number\n\tPageSize int // Number of results per page\n}\n\n// NewSuiteStatusParams creates a new SuiteStatusParams\nfunc NewSuiteStatusParams() *SuiteStatusParams {\n\treturn &SuiteStatusParams{\n\t\tPage:     1,\n\t\tPageSize: 20,\n\t}\n}\n\n// WithPagination sets the page and page size\nfunc (params *SuiteStatusParams) WithPagination(page, pageSize int) *SuiteStatusParams {\n\tparams.Page = page\n\tparams.PageSize = pageSize\n\treturn params\n}"
  },
  {
    "path": "storage/store/common/paging/suite_status_params_test.go",
    "content": "package paging\n\nimport (\n\t\"testing\"\n)\n\nfunc TestNewSuiteStatusParams(t *testing.T) {\n\tparams := NewSuiteStatusParams()\n\tif params == nil {\n\t\tt.Fatal(\"NewSuiteStatusParams should not return nil\")\n\t}\n\tif params.Page != 1 {\n\t\tt.Errorf(\"expected default Page to be 1, got %d\", params.Page)\n\t}\n\tif params.PageSize != 20 {\n\t\tt.Errorf(\"expected default PageSize to be 20, got %d\", params.PageSize)\n\t}\n}\n\nfunc TestSuiteStatusParams_WithPagination(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tpage         int\n\t\tpageSize     int\n\t\texpectedPage int\n\t\texpectedSize int\n\t}{\n\t\t{\n\t\t\tname:         \"valid pagination\",\n\t\t\tpage:         2,\n\t\t\tpageSize:     50,\n\t\t\texpectedPage: 2,\n\t\t\texpectedSize: 50,\n\t\t},\n\t\t{\n\t\t\tname:         \"zero page\",\n\t\t\tpage:         0,\n\t\t\tpageSize:     10,\n\t\t\texpectedPage: 0,\n\t\t\texpectedSize: 10,\n\t\t},\n\t\t{\n\t\t\tname:         \"negative page\",\n\t\t\tpage:         -1,\n\t\t\tpageSize:     20,\n\t\t\texpectedPage: -1,\n\t\t\texpectedSize: 20,\n\t\t},\n\t\t{\n\t\t\tname:         \"zero page size\",\n\t\t\tpage:         1,\n\t\t\tpageSize:     0,\n\t\t\texpectedPage: 1,\n\t\t\texpectedSize: 0,\n\t\t},\n\t\t{\n\t\t\tname:         \"negative page size\",\n\t\t\tpage:         1,\n\t\t\tpageSize:     -10,\n\t\t\texpectedPage: 1,\n\t\t\texpectedSize: -10,\n\t\t},\n\t\t{\n\t\t\tname:         \"large values\",\n\t\t\tpage:         1000,\n\t\t\tpageSize:     10000,\n\t\t\texpectedPage: 1000,\n\t\t\texpectedSize: 10000,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tparams := NewSuiteStatusParams().WithPagination(tt.page, tt.pageSize)\n\t\t\tif params.Page != tt.expectedPage {\n\t\t\t\tt.Errorf(\"expected Page to be %d, got %d\", tt.expectedPage, params.Page)\n\t\t\t}\n\t\t\tif params.PageSize != tt.expectedSize {\n\t\t\t\tt.Errorf(\"expected PageSize to be %d, got %d\", tt.expectedSize, params.PageSize)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSuiteStatusParams_ChainedMethods(t *testing.T) {\n\tparams := NewSuiteStatusParams().\n\t\tWithPagination(3, 100)\n\t\n\tif params.Page != 3 {\n\t\tt.Errorf(\"expected Page to be 3, got %d\", params.Page)\n\t}\n\tif params.PageSize != 100 {\n\t\tt.Errorf(\"expected PageSize to be 100, got %d\", params.PageSize)\n\t}\n}\n\nfunc TestSuiteStatusParams_OverwritePagination(t *testing.T) {\n\tparams := NewSuiteStatusParams()\n\t\n\t// Set initial pagination\n\tparams.WithPagination(2, 50)\n\tif params.Page != 2 || params.PageSize != 50 {\n\t\tt.Error(\"initial pagination not set correctly\")\n\t}\n\t\n\t// Overwrite pagination\n\tparams.WithPagination(5, 200)\n\tif params.Page != 5 {\n\t\tt.Errorf(\"expected Page to be overwritten to 5, got %d\", params.Page)\n\t}\n\tif params.PageSize != 200 {\n\t\tt.Errorf(\"expected PageSize to be overwritten to 200, got %d\", params.PageSize)\n\t}\n}\n\nfunc TestSuiteStatusParams_ReturnsSelf(t *testing.T) {\n\tparams := NewSuiteStatusParams()\n\t\n\t// Verify WithPagination returns the same instance\n\tresult := params.WithPagination(1, 20)\n\tif result != params {\n\t\tt.Error(\"WithPagination should return the same instance for method chaining\")\n\t}\n}"
  },
  {
    "path": "storage/store/memory/memory.go",
    "content": "package memory\n\nimport (\n\t\"slices\"\n\t\"sort\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/key\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n\t\"github.com/TwiN/gocache/v2\"\n\t\"github.com/TwiN/logr\"\n)\n\n// Store that leverages gocache\ntype Store struct {\n\tsync.RWMutex\n\n\tendpointCache *gocache.Cache // Cache for endpoint statuses\n\tsuiteCache    *gocache.Cache // Cache for suite statuses\n\n\tmaximumNumberOfResults int // maximum number of results that an endpoint can have\n\tmaximumNumberOfEvents  int // maximum number of events that an endpoint can have\n}\n\n// NewStore creates a new store using gocache.Cache\n//\n// This store holds everything in memory, and if the file parameter is not blank,\n// supports eventual persistence.\nfunc NewStore(maximumNumberOfResults, maximumNumberOfEvents int) (*Store, error) {\n\tstore := &Store{\n\t\tendpointCache:          gocache.NewCache().WithMaxSize(gocache.NoMaxSize),\n\t\tsuiteCache:             gocache.NewCache().WithMaxSize(gocache.NoMaxSize),\n\t\tmaximumNumberOfResults: maximumNumberOfResults,\n\t\tmaximumNumberOfEvents:  maximumNumberOfEvents,\n\t}\n\treturn store, nil\n}\n\n// GetAllEndpointStatuses returns all monitored endpoint.Status\n// with a subset of endpoint.Result defined by the page and pageSize parameters\nfunc (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error) {\n\ts.RLock()\n\tdefer s.RUnlock()\n\tallStatuses := s.endpointCache.GetAll()\n\tpagedEndpointStatuses := make([]*endpoint.Status, 0, len(allStatuses))\n\tfor _, v := range allStatuses {\n\t\tif status, ok := v.(*endpoint.Status); ok {\n\t\t\tpagedEndpointStatuses = append(pagedEndpointStatuses, ShallowCopyEndpointStatus(status, params))\n\t\t}\n\t}\n\tsort.Slice(pagedEndpointStatuses, func(i, j int) bool {\n\t\treturn pagedEndpointStatuses[i].Key < pagedEndpointStatuses[j].Key\n\t})\n\treturn pagedEndpointStatuses, nil\n}\n\n// GetAllSuiteStatuses returns all monitored suite.Status\nfunc (s *Store) GetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.Status, error) {\n\ts.RLock()\n\tdefer s.RUnlock()\n\tsuiteStatuses := make([]*suite.Status, 0)\n\tfor _, v := range s.suiteCache.GetAll() {\n\t\tif status, ok := v.(*suite.Status); ok {\n\t\t\tsuiteStatuses = append(suiteStatuses, ShallowCopySuiteStatus(status, params))\n\t\t}\n\t}\n\tsort.Slice(suiteStatuses, func(i, j int) bool {\n\t\treturn suiteStatuses[i].Key < suiteStatuses[j].Key\n\t})\n\treturn suiteStatuses, nil\n}\n\n// GetEndpointStatus returns the endpoint status for a given endpoint name in the given group\nfunc (s *Store) GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*endpoint.Status, error) {\n\treturn s.GetEndpointStatusByKey(key.ConvertGroupAndNameToKey(groupName, endpointName), params)\n}\n\n// GetEndpointStatusByKey returns the endpoint status for a given key\nfunc (s *Store) GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error) {\n\ts.RLock()\n\tdefer s.RUnlock()\n\tendpointStatus := s.endpointCache.GetValue(key)\n\tif endpointStatus == nil {\n\t\treturn nil, common.ErrEndpointNotFound\n\t}\n\treturn ShallowCopyEndpointStatus(endpointStatus.(*endpoint.Status), params), nil\n}\n\n// GetSuiteStatusByKey returns the suite status for a given key\nfunc (s *Store) GetSuiteStatusByKey(key string, params *paging.SuiteStatusParams) (*suite.Status, error) {\n\ts.RLock()\n\tdefer s.RUnlock()\n\tsuiteStatus := s.suiteCache.GetValue(key)\n\tif suiteStatus == nil {\n\t\treturn nil, common.ErrSuiteNotFound\n\t}\n\treturn ShallowCopySuiteStatus(suiteStatus.(*suite.Status), params), nil\n}\n\n// GetUptimeByKey returns the uptime percentage during a time range\nfunc (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) {\n\tif from.After(to) {\n\t\treturn 0, common.ErrInvalidTimeRange\n\t}\n\ts.RLock()\n\tdefer s.RUnlock()\n\tendpointStatus := s.endpointCache.GetValue(key)\n\tif endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil {\n\t\treturn 0, common.ErrEndpointNotFound\n\t}\n\tsuccessfulExecutions := uint64(0)\n\ttotalExecutions := uint64(0)\n\tcurrent := from\n\tfor to.Sub(current) >= 0 {\n\t\thourlyUnixTimestamp := current.Truncate(time.Hour).Unix()\n\t\thourlyStats := endpointStatus.(*endpoint.Status).Uptime.HourlyStatistics[hourlyUnixTimestamp]\n\t\tif hourlyStats == nil || hourlyStats.TotalExecutions == 0 {\n\t\t\tcurrent = current.Add(time.Hour)\n\t\t\tcontinue\n\t\t}\n\t\tsuccessfulExecutions += hourlyStats.SuccessfulExecutions\n\t\ttotalExecutions += hourlyStats.TotalExecutions\n\t\tcurrent = current.Add(time.Hour)\n\t}\n\tif totalExecutions == 0 {\n\t\treturn 0, nil\n\t}\n\treturn float64(successfulExecutions) / float64(totalExecutions), nil\n}\n\n// GetAverageResponseTimeByKey returns the average response time in milliseconds (value) during a time range\nfunc (s *Store) GetAverageResponseTimeByKey(key string, from, to time.Time) (int, error) {\n\tif from.After(to) {\n\t\treturn 0, common.ErrInvalidTimeRange\n\t}\n\ts.RLock()\n\tdefer s.RUnlock()\n\tendpointStatus := s.endpointCache.GetValue(key)\n\tif endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil {\n\t\treturn 0, common.ErrEndpointNotFound\n\t}\n\tcurrent := from\n\tvar totalExecutions, totalResponseTime uint64\n\tfor to.Sub(current) >= 0 {\n\t\thourlyUnixTimestamp := current.Truncate(time.Hour).Unix()\n\t\thourlyStats := endpointStatus.(*endpoint.Status).Uptime.HourlyStatistics[hourlyUnixTimestamp]\n\t\tif hourlyStats == nil || hourlyStats.TotalExecutions == 0 {\n\t\t\tcurrent = current.Add(time.Hour)\n\t\t\tcontinue\n\t\t}\n\t\ttotalExecutions += hourlyStats.TotalExecutions\n\t\ttotalResponseTime += hourlyStats.TotalExecutionsResponseTime\n\t\tcurrent = current.Add(time.Hour)\n\t}\n\tif totalExecutions == 0 {\n\t\treturn 0, nil\n\t}\n\treturn int(float64(totalResponseTime) / float64(totalExecutions)), nil\n}\n\n// GetHourlyAverageResponseTimeByKey returns a map of hourly (key) average response time in milliseconds (value) during a time range\nfunc (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error) {\n\tif from.After(to) {\n\t\treturn nil, common.ErrInvalidTimeRange\n\t}\n\ts.RLock()\n\tdefer s.RUnlock()\n\tendpointStatus := s.endpointCache.GetValue(key)\n\tif endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil {\n\t\treturn nil, common.ErrEndpointNotFound\n\t}\n\thourlyAverageResponseTimes := make(map[int64]int)\n\tcurrent := from\n\tfor to.Sub(current) >= 0 {\n\t\thourlyUnixTimestamp := current.Truncate(time.Hour).Unix()\n\t\thourlyStats := endpointStatus.(*endpoint.Status).Uptime.HourlyStatistics[hourlyUnixTimestamp]\n\t\tif hourlyStats == nil || hourlyStats.TotalExecutions == 0 {\n\t\t\tcurrent = current.Add(time.Hour)\n\t\t\tcontinue\n\t\t}\n\t\thourlyAverageResponseTimes[hourlyUnixTimestamp] = int(float64(hourlyStats.TotalExecutionsResponseTime) / float64(hourlyStats.TotalExecutions))\n\t\tcurrent = current.Add(time.Hour)\n\t}\n\treturn hourlyAverageResponseTimes, nil\n}\n\n// InsertEndpointResult adds the observed result for the specified endpoint into the store\nfunc (s *Store) InsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Result) error {\n\tendpointKey := ep.Key()\n\ts.Lock()\n\tstatus, exists := s.endpointCache.Get(endpointKey)\n\tif !exists {\n\t\tstatus = endpoint.NewStatus(ep.Group, ep.Name)\n\t\tstatus.(*endpoint.Status).Events = append(status.(*endpoint.Status).Events, &endpoint.Event{\n\t\t\tType:      endpoint.EventStart,\n\t\t\tTimestamp: time.Now(),\n\t\t})\n\t}\n\tAddResult(status.(*endpoint.Status), result, s.maximumNumberOfResults, s.maximumNumberOfEvents)\n\ts.endpointCache.Set(endpointKey, status)\n\ts.Unlock()\n\treturn nil\n}\n\n// InsertSuiteResult adds the observed result for the specified suite into the store\nfunc (s *Store) InsertSuiteResult(su *suite.Suite, result *suite.Result) error {\n\ts.Lock()\n\tdefer s.Unlock()\n\tsuiteKey := su.Key()\n\tsuiteStatus := s.suiteCache.GetValue(suiteKey)\n\tif suiteStatus == nil {\n\t\tsuiteStatus = &suite.Status{\n\t\t\tName:    su.Name,\n\t\t\tGroup:   su.Group,\n\t\t\tKey:     su.Key(),\n\t\t\tResults: []*suite.Result{},\n\t\t}\n\t\tlogr.Debugf(\"[memory.InsertSuiteResult] Created new suite status for suiteKey=%s\", suiteKey)\n\t}\n\tstatus := suiteStatus.(*suite.Status)\n\t// Add the new result at the end (append like endpoint implementation)\n\tstatus.Results = append(status.Results, result)\n\t// Keep only the maximum number of results\n\tif len(status.Results) > s.maximumNumberOfResults {\n\t\tstatus.Results = status.Results[len(status.Results)-s.maximumNumberOfResults:]\n\t}\n\ts.suiteCache.Set(suiteKey, status)\n\tlogr.Debugf(\"[memory.InsertSuiteResult] Stored suite result for suiteKey=%s, total results=%d\", suiteKey, len(status.Results))\n\treturn nil\n}\n\n// DeleteAllEndpointStatusesNotInKeys removes all Status that are not within the keys provided\nfunc (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int {\n\tvar keysToDelete []string\n\tfor _, existingKey := range s.endpointCache.GetKeysByPattern(\"*\", 0) {\n\t\tshouldDelete := !slices.Contains(keys, existingKey)\n\t\tif shouldDelete {\n\t\t\tkeysToDelete = append(keysToDelete, existingKey)\n\t\t}\n\t}\n\treturn s.endpointCache.DeleteAll(keysToDelete)\n}\n\n// DeleteAllSuiteStatusesNotInKeys removes all suite statuses that are not within the keys provided\nfunc (s *Store) DeleteAllSuiteStatusesNotInKeys(keys []string) int {\n\ts.Lock()\n\tdefer s.Unlock()\n\tkeysToKeep := make(map[string]bool, len(keys))\n\tfor _, k := range keys {\n\t\tkeysToKeep[k] = true\n\t}\n\tvar keysToDelete []string\n\tfor existingKey := range s.suiteCache.GetAll() {\n\t\tif !keysToKeep[existingKey] {\n\t\t\tkeysToDelete = append(keysToDelete, existingKey)\n\t\t}\n\t}\n\treturn s.suiteCache.DeleteAll(keysToDelete)\n}\n\n// GetTriggeredEndpointAlert returns whether the triggered alert for the specified endpoint as well as the necessary information to resolve it\n//\n// Always returns that the alert does not exist for the in-memory store since it does not support persistence across restarts\nfunc (s *Store) GetTriggeredEndpointAlert(ep *endpoint.Endpoint, alert *alert.Alert) (exists bool, resolveKey string, numberOfSuccessesInARow int, err error) {\n\treturn false, \"\", 0, nil\n}\n\n// UpsertTriggeredEndpointAlert inserts/updates a triggered alert for an endpoint\n// Used for persistence of triggered alerts across application restarts\n//\n// Does nothing for the in-memory store since it does not support persistence across restarts\nfunc (s *Store) UpsertTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error {\n\treturn nil\n}\n\n// DeleteTriggeredEndpointAlert deletes a triggered alert for an endpoint\n//\n// Does nothing for the in-memory store since it does not support persistence across restarts\nfunc (s *Store) DeleteTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error {\n\treturn nil\n}\n\n// DeleteAllTriggeredAlertsNotInChecksumsByEndpoint removes all triggered alerts owned by an endpoint whose alert\n// configurations are not provided in the checksums list.\n// This prevents triggered alerts that have been removed or modified from lingering in the database.\n//\n// Does nothing for the in-memory store since it does not support persistence across restarts\nfunc (s *Store) DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int {\n\treturn 0\n}\n\n// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp\nfunc (s *Store) HasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error) {\n\ts.RLock()\n\tdefer s.RUnlock()\n\tendpointStatus := s.endpointCache.GetValue(key)\n\tif endpointStatus == nil {\n\t\t// If no endpoint exists, there's no newer status, so return false instead of an error\n\t\treturn false, nil\n\t}\n\tstatus, ok := endpointStatus.(*endpoint.Status)\n\tif !ok {\n\t\treturn false, nil\n\t}\n\tfor _, result := range status.Results {\n\t\tif result.Timestamp.After(timestamp) {\n\t\t\treturn true, nil\n\t\t}\n\t}\n\treturn false, nil\n}\n\n// Clear deletes everything from the store\nfunc (s *Store) Clear() {\n\ts.endpointCache.Clear()\n\ts.suiteCache.Clear()\n}\n\n// Save persists the cache to the store file\nfunc (s *Store) Save() error {\n\treturn nil\n}\n\n// Close does nothing, because there's nothing to close\nfunc (s *Store) Close() {\n\treturn\n}\n"
  },
  {
    "path": "storage/store/memory/memory_test.go",
    "content": "package memory\n\nimport (\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n)\n\nvar (\n\tfirstCondition  = endpoint.Condition(\"[STATUS] == 200\")\n\tsecondCondition = endpoint.Condition(\"[RESPONSE_TIME] < 500\")\n\tthirdCondition  = endpoint.Condition(\"[CERTIFICATE_EXPIRATION] < 72h\")\n\n\tnow = time.Now()\n\n\ttestEndpoint = endpoint.Endpoint{\n\t\tName:                    \"name\",\n\t\tGroup:                   \"group\",\n\t\tURL:                     \"https://example.org/what/ever\",\n\t\tMethod:                  \"GET\",\n\t\tBody:                    \"body\",\n\t\tInterval:                30 * time.Second,\n\t\tConditions:              []endpoint.Condition{firstCondition, secondCondition, thirdCondition},\n\t\tAlerts:                  nil,\n\t\tNumberOfFailuresInARow:  0,\n\t\tNumberOfSuccessesInARow: 0,\n\t}\n\ttestSuccessfulResult = endpoint.Result{\n\t\tHostname:              \"example.org\",\n\t\tIP:                    \"127.0.0.1\",\n\t\tHTTPStatus:            200,\n\t\tErrors:                nil,\n\t\tConnected:             true,\n\t\tSuccess:               true,\n\t\tTimestamp:             now,\n\t\tDuration:              150 * time.Millisecond,\n\t\tCertificateExpiration: 10 * time.Hour,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{\n\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[CERTIFICATE_EXPIRATION] < 72h\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t},\n\t}\n\ttestUnsuccessfulResult = endpoint.Result{\n\t\tHostname:              \"example.org\",\n\t\tIP:                    \"127.0.0.1\",\n\t\tHTTPStatus:            200,\n\t\tErrors:                []string{\"error-1\", \"error-2\"},\n\t\tConnected:             true,\n\t\tSuccess:               false,\n\t\tTimestamp:             now,\n\t\tDuration:              750 * time.Millisecond,\n\t\tCertificateExpiration: 10 * time.Hour,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{\n\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\tSuccess:   false,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[CERTIFICATE_EXPIRATION] < 72h\",\n\t\t\t\tSuccess:   false,\n\t\t\t},\n\t\t},\n\t}\n)\n\n// Note that are much more extensive tests in /storage/store/store_test.go.\n// This test is simply an extra sanity check\nfunc TestStore_SanityCheck(t *testing.T) {\n\tstore, _ := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Clear()\n\tdefer store.Close()\n\tstore.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\tendpointStatuses, _ := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())\n\tif numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {\n\t\tt.Fatalf(\"expected 1 EndpointStatus, got %d\", numberOfEndpointStatuses)\n\t}\n\tstore.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)\n\t// Both results inserted are for the same endpoint, therefore, the count shouldn't have increased\n\tendpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())\n\tif numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {\n\t\tt.Fatalf(\"expected 1 EndpointStatus, got %d\", numberOfEndpointStatuses)\n\t}\n\tif hourlyAverageResponseTime, err := store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); err != nil {\n\t\tt.Errorf(\"expected no error, got %v\", err)\n\t} else if len(hourlyAverageResponseTime) != 1 {\n\t\tt.Errorf(\"expected 1 hour to have had a result in the past 24 hours, got %d\", len(hourlyAverageResponseTime))\n\t}\n\tif uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); uptime != 0.5 {\n\t\tt.Errorf(\"expected uptime of last 24h to be 0.5, got %f\", uptime)\n\t}\n\tif averageResponseTime, _ := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); averageResponseTime != 450 {\n\t\tt.Errorf(\"expected average response time of last 24h to be 450, got %d\", averageResponseTime)\n\t}\n\tss, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, 20).WithEvents(1, 20))\n\tif ss == nil {\n\t\tt.Fatalf(\"Store should've had key '%s', but didn't\", testEndpoint.Key())\n\t}\n\tif len(ss.Events) != 3 {\n\t\tt.Errorf(\"Endpoint '%s' should've had 3 events, got %d\", ss.Name, len(ss.Events))\n\t}\n\tif len(ss.Results) != 2 {\n\t\tt.Errorf(\"Endpoint '%s' should've had 2 results, got %d\", ss.Name, len(ss.Results))\n\t}\n\tif deleted := store.DeleteAllEndpointStatusesNotInKeys([]string{}); deleted != 1 {\n\t\tt.Errorf(\"%d entries should've been deleted, got %d\", 1, deleted)\n\t}\n}\n\nfunc TestStore_Save(t *testing.T) {\n\tstore, err := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\terr = store.Save()\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tstore.Clear()\n\tstore.Close()\n}\n\nfunc TestStore_HasEndpointStatusNewerThan(t *testing.T) {\n\tstore, _ := NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Clear()\n\tdefer store.Close()\n\t// InsertEndpointResult a result\n\terr := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error while inserting result, got %v\", err)\n\t}\n\t// Check with a timestamp in the past\n\thasNewerStatus, err := store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-time.Hour))\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif !hasNewerStatus {\n\t\tt.Fatal(\"expected to have a newer status, but didn't\")\n\t}\n\t// Check with a timestamp in the future\n\thasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(time.Hour))\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif hasNewerStatus {\n\t\tt.Fatal(\"expected not to have a newer status, but did\")\n\t}\n}\n\n// TestStore_MixedEndpointsAndSuites tests that having both endpoints and suites in the cache\n// doesn't cause issues with core operations\nfunc TestStore_MixedEndpointsAndSuites(t *testing.T) {\n\t// Helper function to create and populate a store with test data\n\tsetupStore := func(t *testing.T) (*Store, *endpoint.Endpoint, *endpoint.Endpoint, *endpoint.Endpoint, *endpoint.Endpoint, *suite.Suite) {\n\t\tstore, err := NewStore(100, 50)\n\t\tif err != nil {\n\t\t\tt.Fatal(\"expected no error, got\", err)\n\t\t}\n\n\t\t// Create regular endpoints\n\t\tendpoint1 := &endpoint.Endpoint{\n\t\t\tName:  \"endpoint1\",\n\t\t\tGroup: \"group1\",\n\t\t\tURL:   \"https://example.com/1\",\n\t\t}\n\t\tendpoint2 := &endpoint.Endpoint{\n\t\t\tName:  \"endpoint2\",\n\t\t\tGroup: \"group2\",\n\t\t\tURL:   \"https://example.com/2\",\n\t\t}\n\n\t\t// Create suite endpoints (these would be part of a suite)\n\t\tsuiteEndpoint1 := &endpoint.Endpoint{\n\t\t\tName:  \"suite-endpoint1\",\n\t\t\tGroup: \"suite-group\",\n\t\t\tURL:   \"https://example.com/suite1\",\n\t\t}\n\t\tsuiteEndpoint2 := &endpoint.Endpoint{\n\t\t\tName:  \"suite-endpoint2\",\n\t\t\tGroup: \"suite-group\",\n\t\t\tURL:   \"https://example.com/suite2\",\n\t\t}\n\n\t\t// Create a suite\n\t\ttestSuite := &suite.Suite{\n\t\t\tName:  \"test-suite\",\n\t\t\tGroup: \"suite-group\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\tsuiteEndpoint1,\n\t\t\t\tsuiteEndpoint2,\n\t\t\t},\n\t\t}\n\n\t\treturn store, endpoint1, endpoint2, suiteEndpoint1, suiteEndpoint2, testSuite\n\t}\n\n\t// Test 1: InsertEndpointResult endpoint results\n\tt.Run(\"InsertEndpointResults\", func(t *testing.T) {\n\t\tstore, endpoint1, endpoint2, suiteEndpoint1, suiteEndpoint2, _ := setupStore(t)\n\t\t// InsertEndpointResult regular endpoint results\n\t\tresult1 := &endpoint.Result{\n\t\t\tSuccess:   true,\n\t\t\tTimestamp: time.Now(),\n\t\t\tDuration:  100 * time.Millisecond,\n\t\t}\n\t\tif err := store.InsertEndpointResult(endpoint1, result1); err != nil {\n\t\t\tt.Fatalf(\"failed to insert endpoint1 result: %v\", err)\n\t\t}\n\n\t\tresult2 := &endpoint.Result{\n\t\t\tSuccess:   false,\n\t\t\tTimestamp: time.Now(),\n\t\t\tDuration:  200 * time.Millisecond,\n\t\t\tErrors:    []string{\"error\"},\n\t\t}\n\t\tif err := store.InsertEndpointResult(endpoint2, result2); err != nil {\n\t\t\tt.Fatalf(\"failed to insert endpoint2 result: %v\", err)\n\t\t}\n\n\t\t// InsertEndpointResult suite endpoint results\n\t\tsuiteResult1 := &endpoint.Result{\n\t\t\tSuccess:   true,\n\t\t\tTimestamp: time.Now(),\n\t\t\tDuration:  50 * time.Millisecond,\n\t\t}\n\t\tif err := store.InsertEndpointResult(suiteEndpoint1, suiteResult1); err != nil {\n\t\t\tt.Fatalf(\"failed to insert suite endpoint1 result: %v\", err)\n\t\t}\n\n\t\tsuiteResult2 := &endpoint.Result{\n\t\t\tSuccess:   true,\n\t\t\tTimestamp: time.Now(),\n\t\t\tDuration:  75 * time.Millisecond,\n\t\t}\n\t\tif err := store.InsertEndpointResult(suiteEndpoint2, suiteResult2); err != nil {\n\t\t\tt.Fatalf(\"failed to insert suite endpoint2 result: %v\", err)\n\t\t}\n\t})\n\n\t// Test 2: InsertEndpointResult suite result\n\tt.Run(\"InsertSuiteResult\", func(t *testing.T) {\n\t\tstore, _, _, _, _, testSuite := setupStore(t)\n\t\ttimestamp := time.Now()\n\t\tsuiteResult := &suite.Result{\n\t\t\tName:      testSuite.Name,\n\t\t\tGroup:     testSuite.Group,\n\t\t\tSuccess:   true,\n\t\t\tTimestamp: timestamp,\n\t\t\tDuration:  125 * time.Millisecond,\n\t\t\tEndpointResults: []*endpoint.Result{\n\t\t\t\t{Success: true, Duration: 50 * time.Millisecond},\n\t\t\t\t{Success: true, Duration: 75 * time.Millisecond},\n\t\t\t},\n\t\t}\n\t\tif err := store.InsertSuiteResult(testSuite, suiteResult); err != nil {\n\t\t\tt.Fatalf(\"failed to insert suite result: %v\", err)\n\t\t}\n\n\t\t// Verify the suite result was stored correctly\n\t\tstatus, err := store.GetSuiteStatusByKey(testSuite.Key(), nil)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get suite status: %v\", err)\n\t\t}\n\t\tif len(status.Results) != 1 {\n\t\t\tt.Errorf(\"expected 1 suite result, got %d\", len(status.Results))\n\t\t}\n\n\t\tstored := status.Results[0]\n\t\tif stored.Name != testSuite.Name {\n\t\t\tt.Errorf(\"expected result name %s, got %s\", testSuite.Name, stored.Name)\n\t\t}\n\t\tif stored.Group != testSuite.Group {\n\t\t\tt.Errorf(\"expected result group %s, got %s\", testSuite.Group, stored.Group)\n\t\t}\n\t\tif !stored.Success {\n\t\t\tt.Error(\"expected result to be successful\")\n\t\t}\n\t\tif stored.Duration != 125*time.Millisecond {\n\t\t\tt.Errorf(\"expected duration 125ms, got %v\", stored.Duration)\n\t\t}\n\t\tif len(stored.EndpointResults) != 2 {\n\t\t\tt.Errorf(\"expected 2 endpoint results, got %d\", len(stored.EndpointResults))\n\t\t}\n\t})\n\n\t// Test 3: GetAllEndpointStatuses should only return endpoints, not suites\n\tt.Run(\"GetAllEndpointStatuses\", func(t *testing.T) {\n\t\tstore, endpoint1, endpoint2, _, _, testSuite := setupStore(t)\n\n\t\t// Insert standalone endpoint results only\n\t\tstore.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})\n\t\tstore.InsertEndpointResult(endpoint2, &endpoint.Result{Success: false, Timestamp: time.Now(), Duration: 200 * time.Millisecond})\n\t\t// Suite endpoints should only exist as part of suite results, not as individual endpoint results\n\t\tstore.InsertSuiteResult(testSuite, &suite.Result{\n\t\t\tName: testSuite.Name, Group: testSuite.Group, Success: true,\n\t\t\tTimestamp: time.Now(), Duration: 125 * time.Millisecond,\n\t\t\tEndpointResults: []*endpoint.Result{\n\t\t\t\t{Success: true, Duration: 50 * time.Millisecond, Name: \"suite-endpoint1\"},\n\t\t\t\t{Success: true, Duration: 75 * time.Millisecond, Name: \"suite-endpoint2\"},\n\t\t\t},\n\t\t})\n\t\tstatuses, err := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get all endpoint statuses: %v\", err)\n\t\t}\n\n\t\t// Should have 2 endpoints (only standalone endpoints, not suite endpoints)\n\t\tif len(statuses) != 2 {\n\t\t\tt.Errorf(\"expected 2 endpoint statuses, got %d\", len(statuses))\n\t\t}\n\n\t\t// Verify all are standalone endpoint statuses with correct data, not suite endpoints\n\t\texpectedEndpoints := map[string]struct {\n\t\t\tsuccess  bool\n\t\t\tduration time.Duration\n\t\t}{\n\t\t\t\"endpoint1\": {success: true, duration: 100 * time.Millisecond},\n\t\t\t\"endpoint2\": {success: false, duration: 200 * time.Millisecond},\n\t\t}\n\n\t\tfor _, status := range statuses {\n\t\t\tif status.Name == \"\" {\n\t\t\t\tt.Error(\"endpoint status should have a name\")\n\t\t\t}\n\t\t\t// Make sure none of them are the suite itself\n\t\t\tif status.Name == \"test-suite\" {\n\t\t\t\tt.Error(\"suite should not appear in endpoint statuses\")\n\t\t\t}\n\n\t\t\t// Verify detailed endpoint data\n\t\t\texpected, exists := expectedEndpoints[status.Name]\n\t\t\tif !exists {\n\t\t\t\tt.Errorf(\"unexpected endpoint name: %s\", status.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Check that endpoint has results and verify the data\n\t\t\tif len(status.Results) != 1 {\n\t\t\t\tt.Errorf(\"endpoint %s should have 1 result, got %d\", status.Name, len(status.Results))\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tresult := status.Results[0]\n\t\t\tif result.Success != expected.success {\n\t\t\t\tt.Errorf(\"endpoint %s result success should be %v, got %v\", status.Name, expected.success, result.Success)\n\t\t\t}\n\t\t\tif result.Duration != expected.duration {\n\t\t\t\tt.Errorf(\"endpoint %s result duration should be %v, got %v\", status.Name, expected.duration, result.Duration)\n\t\t\t}\n\n\t\t\tdelete(expectedEndpoints, status.Name)\n\t\t}\n\t\tif len(expectedEndpoints) > 0 {\n\t\t\tt.Errorf(\"missing expected endpoints: %v\", expectedEndpoints)\n\t\t}\n\t})\n\n\t// Test 4: GetAllSuiteStatuses should only return suites, not endpoints\n\tt.Run(\"GetAllSuiteStatuses\", func(t *testing.T) {\n\t\tstore, endpoint1, _, _, _, testSuite := setupStore(t)\n\n\t\t// InsertEndpointResult test data\n\t\tstore.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})\n\t\ttimestamp := time.Now()\n\t\tstore.InsertSuiteResult(testSuite, &suite.Result{\n\t\t\tName: testSuite.Name, Group: testSuite.Group, Success: true,\n\t\t\tTimestamp: timestamp, Duration: 125 * time.Millisecond,\n\t\t})\n\t\tstatuses, err := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get all suite statuses: %v\", err)\n\t\t}\n\n\t\t// Should have 1 suite\n\t\tif len(statuses) != 1 {\n\t\t\tt.Errorf(\"expected 1 suite status, got %d\", len(statuses))\n\t\t}\n\n\t\tif len(statuses) > 0 {\n\t\t\tsuiteStatus := statuses[0]\n\t\t\tif suiteStatus.Name != \"test-suite\" {\n\t\t\t\tt.Errorf(\"expected suite name 'test-suite', got '%s'\", suiteStatus.Name)\n\t\t\t}\n\t\t\tif suiteStatus.Group != \"suite-group\" {\n\t\t\t\tt.Errorf(\"expected suite group 'suite-group', got '%s'\", suiteStatus.Group)\n\t\t\t}\n\t\t\tif len(suiteStatus.Results) != 1 {\n\t\t\t\tt.Errorf(\"expected 1 suite result, got %d\", len(suiteStatus.Results))\n\t\t\t}\n\t\t\tif len(suiteStatus.Results) > 0 {\n\t\t\t\tresult := suiteStatus.Results[0]\n\t\t\t\tif !result.Success {\n\t\t\t\t\tt.Error(\"expected suite result to be successful\")\n\t\t\t\t}\n\t\t\t\tif result.Duration != 125*time.Millisecond {\n\t\t\t\t\tt.Errorf(\"expected suite result duration 125ms, got %v\", result.Duration)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\t// Test 5: GetEndpointStatusByKey should work for all endpoints\n\tt.Run(\"GetEndpointStatusByKey\", func(t *testing.T) {\n\t\tstore, endpoint1, _, suiteEndpoint1, _, _ := setupStore(t)\n\n\t\t// InsertEndpointResult test data with specific timestamps and durations\n\t\ttimestamp1 := time.Now()\n\t\ttimestamp2 := time.Now().Add(1 * time.Hour)\n\t\tstore.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: timestamp1, Duration: 100 * time.Millisecond})\n\t\tstore.InsertEndpointResult(suiteEndpoint1, &endpoint.Result{Success: false, Timestamp: timestamp2, Duration: 50 * time.Millisecond, Errors: []string{\"suite error\"}})\n\n\t\t// Test regular endpoints\n\t\tstatus1, err := store.GetEndpointStatusByKey(endpoint1.Key(), &paging.EndpointStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get endpoint1 status: %v\", err)\n\t\t}\n\t\tif status1.Name != \"endpoint1\" {\n\t\t\tt.Errorf(\"expected endpoint1, got %s\", status1.Name)\n\t\t}\n\t\tif status1.Group != \"group1\" {\n\t\t\tt.Errorf(\"expected group1, got %s\", status1.Group)\n\t\t}\n\t\tif len(status1.Results) != 1 {\n\t\t\tt.Errorf(\"expected 1 result for endpoint1, got %d\", len(status1.Results))\n\t\t}\n\t\tif len(status1.Results) > 0 {\n\t\t\tresult := status1.Results[0]\n\t\t\tif !result.Success {\n\t\t\t\tt.Error(\"expected endpoint1 result to be successful\")\n\t\t\t}\n\t\t\tif result.Duration != 100*time.Millisecond {\n\t\t\t\tt.Errorf(\"expected endpoint1 result duration 100ms, got %v\", result.Duration)\n\t\t\t}\n\t\t}\n\n\t\t// Test suite endpoints\n\t\tsuiteStatus1, err := store.GetEndpointStatusByKey(suiteEndpoint1.Key(), &paging.EndpointStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get suite endpoint1 status: %v\", err)\n\t\t}\n\t\tif suiteStatus1.Name != \"suite-endpoint1\" {\n\t\t\tt.Errorf(\"expected suite-endpoint1, got %s\", suiteStatus1.Name)\n\t\t}\n\t\tif suiteStatus1.Group != \"suite-group\" {\n\t\t\tt.Errorf(\"expected suite-group, got %s\", suiteStatus1.Group)\n\t\t}\n\t\tif len(suiteStatus1.Results) != 1 {\n\t\t\tt.Errorf(\"expected 1 result for suite-endpoint1, got %d\", len(suiteStatus1.Results))\n\t\t}\n\t\tif len(suiteStatus1.Results) > 0 {\n\t\t\tresult := suiteStatus1.Results[0]\n\t\t\tif result.Success {\n\t\t\t\tt.Error(\"expected suite-endpoint1 result to be unsuccessful\")\n\t\t\t}\n\t\t\tif result.Duration != 50*time.Millisecond {\n\t\t\t\tt.Errorf(\"expected suite-endpoint1 result duration 50ms, got %v\", result.Duration)\n\t\t\t}\n\t\t\tif len(result.Errors) != 1 || result.Errors[0] != \"suite error\" {\n\t\t\t\tt.Errorf(\"expected suite-endpoint1 to have error 'suite error', got %v\", result.Errors)\n\t\t\t}\n\t\t}\n\t})\n\n\t// Test 6: GetSuiteStatusByKey should work for suites\n\tt.Run(\"GetSuiteStatusByKey\", func(t *testing.T) {\n\t\tstore, _, _, _, _, testSuite := setupStore(t)\n\n\t\t// InsertEndpointResult suite result with endpoint results\n\t\ttimestamp := time.Now()\n\t\tstore.InsertSuiteResult(testSuite, &suite.Result{\n\t\t\tName: testSuite.Name, Group: testSuite.Group, Success: false,\n\t\t\tTimestamp: timestamp, Duration: 125 * time.Millisecond,\n\t\t\tEndpointResults: []*endpoint.Result{\n\t\t\t\t{Success: true, Duration: 50 * time.Millisecond},\n\t\t\t\t{Success: false, Duration: 75 * time.Millisecond, Errors: []string{\"endpoint failed\"}},\n\t\t\t},\n\t\t})\n\t\tsuiteStatus, err := store.GetSuiteStatusByKey(testSuite.Key(), &paging.SuiteStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get suite status: %v\", err)\n\t\t}\n\t\tif suiteStatus.Name != \"test-suite\" {\n\t\t\tt.Errorf(\"expected test-suite, got %s\", suiteStatus.Name)\n\t\t}\n\t\tif suiteStatus.Group != \"suite-group\" {\n\t\t\tt.Errorf(\"expected suite-group, got %s\", suiteStatus.Group)\n\t\t}\n\t\tif len(suiteStatus.Results) != 1 {\n\t\t\tt.Errorf(\"expected 1 suite result, got %d\", len(suiteStatus.Results))\n\t\t}\n\n\t\tif len(suiteStatus.Results) > 0 {\n\t\t\tresult := suiteStatus.Results[0]\n\t\t\tif result.Success {\n\t\t\t\tt.Error(\"expected suite result to be unsuccessful\")\n\t\t\t}\n\t\t\tif result.Duration != 125*time.Millisecond {\n\t\t\t\tt.Errorf(\"expected suite result duration 125ms, got %v\", result.Duration)\n\t\t\t}\n\t\t\tif len(result.EndpointResults) != 2 {\n\t\t\t\tt.Errorf(\"expected 2 endpoint results, got %d\", len(result.EndpointResults))\n\t\t\t}\n\t\t\tif len(result.EndpointResults) >= 2 {\n\t\t\t\tif !result.EndpointResults[0].Success {\n\t\t\t\t\tt.Error(\"expected first endpoint result to be successful\")\n\t\t\t\t}\n\t\t\t\tif result.EndpointResults[1].Success {\n\t\t\t\t\tt.Error(\"expected second endpoint result to be unsuccessful\")\n\t\t\t\t}\n\t\t\t\tif len(result.EndpointResults[1].Errors) != 1 || result.EndpointResults[1].Errors[0] != \"endpoint failed\" {\n\t\t\t\t\tt.Errorf(\"expected second endpoint to have error 'endpoint failed', got %v\", result.EndpointResults[1].Errors)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\t// Test 7: DeleteAllEndpointStatusesNotInKeys should not affect suites\n\tt.Run(\"DeleteEndpointsNotInKeys\", func(t *testing.T) {\n\t\tstore, endpoint1, endpoint2, suiteEndpoint1, suiteEndpoint2, testSuite := setupStore(t)\n\n\t\t// InsertEndpointResult all test data\n\t\tstore.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})\n\t\tstore.InsertEndpointResult(endpoint2, &endpoint.Result{Success: false, Timestamp: time.Now(), Duration: 200 * time.Millisecond})\n\t\tstore.InsertEndpointResult(suiteEndpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 50 * time.Millisecond})\n\t\tstore.InsertEndpointResult(suiteEndpoint2, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 75 * time.Millisecond})\n\t\tstore.InsertSuiteResult(testSuite, &suite.Result{\n\t\t\tName: testSuite.Name, Group: testSuite.Group, Success: true,\n\t\t\tTimestamp: time.Now(), Duration: 125 * time.Millisecond,\n\t\t})\n\t\t// Keep only endpoint1 and suite-endpoint1\n\t\tkeysToKeep := []string{endpoint1.Key(), suiteEndpoint1.Key()}\n\t\tdeleted := store.DeleteAllEndpointStatusesNotInKeys(keysToKeep)\n\n\t\t// Should have deleted 2 endpoints (endpoint2 and suite-endpoint2)\n\t\tif deleted != 2 {\n\t\t\tt.Errorf(\"expected to delete 2 endpoints, deleted %d\", deleted)\n\t\t}\n\n\t\t// Verify remaining endpoints\n\t\tstatuses, _ := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})\n\t\tif len(statuses) != 2 {\n\t\t\tt.Errorf(\"expected 2 remaining endpoint statuses, got %d\", len(statuses))\n\t\t}\n\n\t\t// Suite should still exist\n\t\tsuiteStatuses, _ := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})\n\t\tif len(suiteStatuses) != 1 {\n\t\t\tt.Errorf(\"suite should not be affected by DeleteAllEndpointStatusesNotInKeys\")\n\t\t}\n\t})\n\n\t// Test 8: DeleteAllSuiteStatusesNotInKeys should not affect endpoints\n\tt.Run(\"DeleteSuitesNotInKeys\", func(t *testing.T) {\n\t\tstore, endpoint1, _, _, _, testSuite := setupStore(t)\n\n\t\t// InsertEndpointResult test data\n\t\tstore.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})\n\t\tstore.InsertSuiteResult(testSuite, &suite.Result{\n\t\t\tName: testSuite.Name, Group: testSuite.Group, Success: true,\n\t\t\tTimestamp: time.Now(), Duration: 125 * time.Millisecond,\n\t\t})\n\t\t// First, add another suite to test deletion\n\t\tanotherSuite := &suite.Suite{\n\t\t\tName:  \"another-suite\",\n\t\t\tGroup: \"another-group\",\n\t\t}\n\t\tanotherSuiteResult := &suite.Result{\n\t\t\tName:      anotherSuite.Name,\n\t\t\tGroup:     anotherSuite.Group,\n\t\t\tSuccess:   true,\n\t\t\tTimestamp: time.Now(),\n\t\t\tDuration:  100 * time.Millisecond,\n\t\t}\n\t\tstore.InsertSuiteResult(anotherSuite, anotherSuiteResult)\n\n\t\t// Keep only the original test-suite\n\t\tdeleted := store.DeleteAllSuiteStatusesNotInKeys([]string{testSuite.Key()})\n\n\t\t// Should have deleted 1 suite (another-suite)\n\t\tif deleted != 1 {\n\t\t\tt.Errorf(\"expected to delete 1 suite, deleted %d\", deleted)\n\t\t}\n\n\t\t// Endpoints should still exist\n\t\tendpointStatuses, _ := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})\n\t\tif len(endpointStatuses) != 1 {\n\t\t\tt.Errorf(\"endpoints should not be affected by DeleteAllSuiteStatusesNotInKeys\")\n\t\t}\n\n\t\t// Only one suite should remain\n\t\tsuiteStatuses, _ := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})\n\t\tif len(suiteStatuses) != 1 {\n\t\t\tt.Errorf(\"expected 1 remaining suite, got %d\", len(suiteStatuses))\n\t\t}\n\t})\n\n\t// Test 9: Clear should remove everything\n\tt.Run(\"Clear\", func(t *testing.T) {\n\t\tstore, endpoint1, _, _, _, testSuite := setupStore(t)\n\n\t\t// InsertEndpointResult test data\n\t\tstore.InsertEndpointResult(endpoint1, &endpoint.Result{Success: true, Timestamp: time.Now(), Duration: 100 * time.Millisecond})\n\t\tstore.InsertSuiteResult(testSuite, &suite.Result{\n\t\t\tName: testSuite.Name, Group: testSuite.Group, Success: true,\n\t\t\tTimestamp: time.Now(), Duration: 125 * time.Millisecond,\n\t\t})\n\t\tstore.Clear()\n\n\t\t// No endpoints should remain\n\t\tendpointStatuses, _ := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})\n\t\tif len(endpointStatuses) != 0 {\n\t\t\tt.Errorf(\"expected 0 endpoints after clear, got %d\", len(endpointStatuses))\n\t\t}\n\n\t\t// No suites should remain\n\t\tsuiteStatuses, _ := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})\n\t\tif len(suiteStatuses) != 0 {\n\t\t\tt.Errorf(\"expected 0 suites after clear, got %d\", len(suiteStatuses))\n\t\t}\n\t})\n}\n\n// TestStore_EndpointStatusCastingSafety tests that type assertions are safe\nfunc TestStore_EndpointStatusCastingSafety(t *testing.T) {\n\tstore, err := NewStore(100, 50)\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err)\n\t}\n\n\t// InsertEndpointResult an endpoint\n\tep := &endpoint.Endpoint{\n\t\tName:  \"test-endpoint\",\n\t\tGroup: \"test\",\n\t\tURL:   \"https://example.com\",\n\t}\n\tresult := &endpoint.Result{\n\t\tSuccess:   true,\n\t\tTimestamp: time.Now(),\n\t\tDuration:  100 * time.Millisecond,\n\t}\n\tstore.InsertEndpointResult(ep, result)\n\n\t// InsertEndpointResult a suite\n\ttestSuite := &suite.Suite{\n\t\tName:  \"test-suite\",\n\t\tGroup: \"test\",\n\t}\n\tsuiteResult := &suite.Result{\n\t\tName:      testSuite.Name,\n\t\tGroup:     testSuite.Group,\n\t\tSuccess:   true,\n\t\tTimestamp: time.Now(),\n\t\tDuration:  200 * time.Millisecond,\n\t}\n\tstore.InsertSuiteResult(testSuite, suiteResult)\n\n\t// This should not panic even with mixed types in cache\n\tstatuses, err := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to get all endpoint statuses: %v\", err)\n\t}\n\n\t// Should only have the endpoint, not the suite\n\tif len(statuses) != 1 {\n\t\tt.Errorf(\"expected 1 endpoint status, got %d\", len(statuses))\n\t}\n\tif statuses[0].Name != \"test-endpoint\" {\n\t\tt.Errorf(\"expected test-endpoint, got %s\", statuses[0].Name)\n\t}\n}\n\nfunc TestStore_MaximumLimits(t *testing.T) {\n\t// Use small limits to test trimming behavior\n\tmaxResults := 5\n\tmaxEvents := 3\n\tstore, err := NewStore(maxResults, maxEvents)\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err)\n\t}\n\tdefer store.Clear()\n\n\tt.Run(\"endpoint-result-limits\", func(t *testing.T) {\n\t\tep := &endpoint.Endpoint{Name: \"test-endpoint\", Group: \"test\", URL: \"https://example.com\"}\n\n\t\t// Insert more results than the maximum\n\t\tbaseTime := time.Now().Add(-10 * time.Hour)\n\t\tfor i := 0; i < maxResults*2; i++ {\n\t\t\tresult := &endpoint.Result{\n\t\t\t\tSuccess:   i%2 == 0,\n\t\t\t\tTimestamp: baseTime.Add(time.Duration(i) * time.Hour),\n\t\t\t\tDuration:  time.Duration(i*10) * time.Millisecond,\n\t\t\t}\n\t\t\terr := store.InsertEndpointResult(ep, result)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to insert result %d: %v\", i, err)\n\t\t\t}\n\t\t}\n\n\t\t// Verify only maxResults are kept\n\t\tstatus, err := store.GetEndpointStatusByKey(ep.Key(), nil)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get endpoint status: %v\", err)\n\t\t}\n\t\tif len(status.Results) != maxResults {\n\t\t\tt.Errorf(\"expected %d results after trimming, got %d\", maxResults, len(status.Results))\n\t\t}\n\n\t\t// Verify the newest results are kept (should be results 5-9, not 0-4)\n\t\tif len(status.Results) > 0 {\n\t\t\tfirstResult := status.Results[0]\n\t\t\tlastResult := status.Results[len(status.Results)-1]\n\t\t\t// First result should be older than last result due to append order\n\t\t\tif !lastResult.Timestamp.After(firstResult.Timestamp) {\n\t\t\t\tt.Error(\"expected results to be in chronological order\")\n\t\t\t}\n\t\t\t// The last result should be the most recent one we inserted\n\t\t\texpectedLastDuration := time.Duration((maxResults*2-1)*10) * time.Millisecond\n\t\t\tif lastResult.Duration != expectedLastDuration {\n\t\t\t\tt.Errorf(\"expected last result duration %v, got %v\", expectedLastDuration, lastResult.Duration)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"suite-result-limits\", func(t *testing.T) {\n\t\ttestSuite := &suite.Suite{Name: \"test-suite\", Group: \"test\"}\n\n\t\t// Insert more results than the maximum\n\t\tbaseTime := time.Now().Add(-10 * time.Hour)\n\t\tfor i := 0; i < maxResults*2; i++ {\n\t\t\tresult := &suite.Result{\n\t\t\t\tName:      testSuite.Name,\n\t\t\t\tGroup:     testSuite.Group,\n\t\t\t\tSuccess:   i%2 == 0,\n\t\t\t\tTimestamp: baseTime.Add(time.Duration(i) * time.Hour),\n\t\t\t\tDuration:  time.Duration(i*10) * time.Millisecond,\n\t\t\t}\n\t\t\terr := store.InsertSuiteResult(testSuite, result)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to insert suite result %d: %v\", i, err)\n\t\t\t}\n\t\t}\n\n\t\t// Verify only maxResults are kept\n\t\tstatus, err := store.GetSuiteStatusByKey(testSuite.Key(), &paging.SuiteStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get suite status: %v\", err)\n\t\t}\n\t\tif len(status.Results) != maxResults {\n\t\t\tt.Errorf(\"expected %d results after trimming, got %d\", maxResults, len(status.Results))\n\t\t}\n\n\t\t// Verify the newest results are kept (should be results 5-9, not 0-4)\n\t\tif len(status.Results) > 0 {\n\t\t\tfirstResult := status.Results[0]\n\t\t\tlastResult := status.Results[len(status.Results)-1]\n\t\t\t// First result should be older than last result due to append order\n\t\t\tif !lastResult.Timestamp.After(firstResult.Timestamp) {\n\t\t\t\tt.Error(\"expected results to be in chronological order\")\n\t\t\t}\n\t\t\t// The last result should be the most recent one we inserted\n\t\t\texpectedLastDuration := time.Duration((maxResults*2-1)*10) * time.Millisecond\n\t\t\tif lastResult.Duration != expectedLastDuration {\n\t\t\t\tt.Errorf(\"expected last result duration %v, got %v\", expectedLastDuration, lastResult.Duration)\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc TestSuiteResultOrdering(t *testing.T) {\n\tstore, err := NewStore(10, 5)\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err)\n\t}\n\tdefer store.Clear()\n\n\ttestSuite := &suite.Suite{Name: \"ordering-suite\", Group: \"test\"}\n\n\t// Insert results with distinct timestamps\n\tbaseTime := time.Now().Add(-5 * time.Hour)\n\ttimestamps := make([]time.Time, 5)\n\n\tfor i := range 5 {\n\t\ttimestamp := baseTime.Add(time.Duration(i) * time.Hour)\n\t\ttimestamps[i] = timestamp\n\t\tresult := &suite.Result{\n\t\t\tName:      testSuite.Name,\n\t\t\tGroup:     testSuite.Group,\n\t\t\tSuccess:   true,\n\t\t\tTimestamp: timestamp,\n\t\t\tDuration:  time.Duration(i*100) * time.Millisecond,\n\t\t}\n\t\terr := store.InsertSuiteResult(testSuite, result)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to insert result %d: %v\", i, err)\n\t\t}\n\t}\n\n\tt.Run(\"chronological-append-order\", func(t *testing.T) {\n\t\tstatus, err := store.GetSuiteStatusByKey(testSuite.Key(), nil)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get suite status: %v\", err)\n\t\t}\n\n\t\t// Verify results are in chronological order (oldest first due to append)\n\t\tfor i := 0; i < len(status.Results)-1; i++ {\n\t\t\tcurrent := status.Results[i]\n\t\t\tnext := status.Results[i+1]\n\t\t\tif !next.Timestamp.After(current.Timestamp) {\n\t\t\t\tt.Errorf(\"result %d timestamp %v should be before result %d timestamp %v\",\n\t\t\t\t\ti, current.Timestamp, i+1, next.Timestamp)\n\t\t\t}\n\t\t}\n\n\t\t// Verify specific timestamp order\n\t\tif !status.Results[0].Timestamp.Equal(timestamps[0]) {\n\t\t\tt.Errorf(\"first result timestamp should be %v, got %v\", timestamps[0], status.Results[0].Timestamp)\n\t\t}\n\t\tif !status.Results[len(status.Results)-1].Timestamp.Equal(timestamps[len(timestamps)-1]) {\n\t\t\tt.Errorf(\"last result timestamp should be %v, got %v\", timestamps[len(timestamps)-1], status.Results[len(status.Results)-1].Timestamp)\n\t\t}\n\t})\n\n\tt.Run(\"pagination-newest-first\", func(t *testing.T) {\n\t\t// Test reverse pagination (newest first in paginated results)\n\t\tpage1 := ShallowCopySuiteStatus(\n\t\t\t&suite.Status{\n\t\t\t\tName: testSuite.Name, Group: testSuite.Group, Key: testSuite.Key(),\n\t\t\t\tResults: []*suite.Result{\n\t\t\t\t\t{Timestamp: timestamps[0], Duration: 0 * time.Millisecond},\n\t\t\t\t\t{Timestamp: timestamps[1], Duration: 100 * time.Millisecond},\n\t\t\t\t\t{Timestamp: timestamps[2], Duration: 200 * time.Millisecond},\n\t\t\t\t\t{Timestamp: timestamps[3], Duration: 300 * time.Millisecond},\n\t\t\t\t\t{Timestamp: timestamps[4], Duration: 400 * time.Millisecond},\n\t\t\t\t},\n\t\t\t},\n\t\t\tpaging.NewSuiteStatusParams().WithPagination(1, 3),\n\t\t)\n\n\t\tif len(page1.Results) != 3 {\n\t\t\tt.Errorf(\"expected 3 results in page 1, got %d\", len(page1.Results))\n\t\t}\n\n\t\t// With reverse pagination, page 1 should have the 3 newest results\n\t\t// That means results[2], results[3], results[4] from original array\n\t\tif page1.Results[0].Duration != 200*time.Millisecond {\n\t\t\tt.Errorf(\"expected first result in page to have 200ms duration, got %v\", page1.Results[0].Duration)\n\t\t}\n\t\tif page1.Results[2].Duration != 400*time.Millisecond {\n\t\t\tt.Errorf(\"expected last result in page to have 400ms duration, got %v\", page1.Results[2].Duration)\n\t\t}\n\t})\n\n\tt.Run(\"trimming-preserves-newest\", func(t *testing.T) {\n\t\tlimitedStore, err := NewStore(3, 2) // Very small limits\n\t\tif err != nil {\n\t\t\tt.Fatal(\"expected no error, got\", err)\n\t\t}\n\t\tdefer limitedStore.Clear()\n\n\t\tsmallSuite := &suite.Suite{Name: \"small-suite\", Group: \"test\"}\n\n\t\t// Insert 6 results, should keep only the newest 3\n\t\tfor i := range 6 {\n\t\t\tresult := &suite.Result{\n\t\t\t\tName:      smallSuite.Name,\n\t\t\t\tGroup:     smallSuite.Group,\n\t\t\t\tSuccess:   true,\n\t\t\t\tTimestamp: baseTime.Add(time.Duration(i) * time.Hour),\n\t\t\t\tDuration:  time.Duration(i*50) * time.Millisecond,\n\t\t\t}\n\t\t\terr := limitedStore.InsertSuiteResult(smallSuite, result)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"failed to insert result %d: %v\", i, err)\n\t\t\t}\n\t\t}\n\n\t\tstatus, err := limitedStore.GetSuiteStatusByKey(smallSuite.Key(), nil)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get suite status: %v\", err)\n\t\t}\n\n\t\tif len(status.Results) != 3 {\n\t\t\tt.Errorf(\"expected 3 results after trimming, got %d\", len(status.Results))\n\t\t}\n\n\t\t// Should have results 3, 4, 5 (the newest ones)\n\t\texpectedDurations := []time.Duration{150 * time.Millisecond, 200 * time.Millisecond, 250 * time.Millisecond}\n\t\tfor i, expectedDuration := range expectedDurations {\n\t\t\tif status.Results[i].Duration != expectedDuration {\n\t\t\t\tt.Errorf(\"result %d should have duration %v, got %v\", i, expectedDuration, status.Results[i].Duration)\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc TestStore_ConcurrentAccess(t *testing.T) {\n\tstore, err := NewStore(100, 50)\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err)\n\t}\n\tdefer store.Clear()\n\n\tt.Run(\"concurrent-endpoint-insertions\", func(t *testing.T) {\n\t\tvar wg sync.WaitGroup\n\t\tnumGoroutines := 10\n\t\tresultsPerGoroutine := 5\n\n\t\t// Create endpoints for concurrent testing\n\t\tendpoints := make([]*endpoint.Endpoint, numGoroutines)\n\t\tfor i := range numGoroutines {\n\t\t\tendpoints[i] = &endpoint.Endpoint{\n\t\t\t\tName:  \"endpoint-\" + string(rune('A'+i)),\n\t\t\t\tGroup: \"concurrent\",\n\t\t\t\tURL:   \"https://example.com/\" + string(rune('A'+i)),\n\t\t\t}\n\t\t}\n\n\t\t// Concurrently insert results for different endpoints\n\t\tfor i := range numGoroutines {\n\t\t\twg.Add(1)\n\t\t\tgo func(endpointIndex int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\tep := endpoints[endpointIndex]\n\t\t\t\tfor j := range resultsPerGoroutine {\n\t\t\t\t\tresult := &endpoint.Result{\n\t\t\t\t\t\tSuccess:   j%2 == 0,\n\t\t\t\t\t\tTimestamp: time.Now().Add(time.Duration(j) * time.Minute),\n\t\t\t\t\t\tDuration:  time.Duration(j*10) * time.Millisecond,\n\t\t\t\t\t}\n\t\t\t\t\tif err := store.InsertEndpointResult(ep, result); err != nil {\n\t\t\t\t\t\tt.Errorf(\"failed to insert result for endpoint %d: %v\", endpointIndex, err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}(i)\n\t\t}\n\n\t\twg.Wait()\n\n\t\t// Verify all endpoints were created and have correct result counts\n\t\tstatuses, err := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get all endpoint statuses: %v\", err)\n\t\t}\n\t\tif len(statuses) != numGoroutines {\n\t\t\tt.Errorf(\"expected %d endpoint statuses, got %d\", numGoroutines, len(statuses))\n\t\t}\n\n\t\t// Verify each endpoint has the correct number of results\n\t\tfor _, status := range statuses {\n\t\t\tif len(status.Results) != resultsPerGoroutine {\n\t\t\t\tt.Errorf(\"endpoint %s should have %d results, got %d\", status.Name, resultsPerGoroutine, len(status.Results))\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"concurrent-suite-insertions\", func(t *testing.T) {\n\t\tvar wg sync.WaitGroup\n\t\tnumGoroutines := 5\n\t\tresultsPerGoroutine := 3\n\n\t\t// Create suites for concurrent testing\n\t\tsuites := make([]*suite.Suite, numGoroutines)\n\t\tfor i := range numGoroutines {\n\t\t\tsuites[i] = &suite.Suite{\n\t\t\t\tName:  \"suite-\" + string(rune('A'+i)),\n\t\t\t\tGroup: \"concurrent\",\n\t\t\t}\n\t\t}\n\n\t\t// Concurrently insert results for different suites\n\t\tfor i := range numGoroutines {\n\t\t\twg.Add(1)\n\t\t\tgo func(suiteIndex int) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\tsu := suites[suiteIndex]\n\t\t\t\tfor j := range resultsPerGoroutine {\n\t\t\t\t\tresult := &suite.Result{\n\t\t\t\t\t\tName:      su.Name,\n\t\t\t\t\t\tGroup:     su.Group,\n\t\t\t\t\t\tSuccess:   j%2 == 0,\n\t\t\t\t\t\tTimestamp: time.Now().Add(time.Duration(j) * time.Minute),\n\t\t\t\t\t\tDuration:  time.Duration(j*50) * time.Millisecond,\n\t\t\t\t\t}\n\t\t\t\t\tif err := store.InsertSuiteResult(su, result); err != nil {\n\t\t\t\t\t\tt.Errorf(\"failed to insert result for suite %d: %v\", suiteIndex, err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}(i)\n\t\t}\n\n\t\twg.Wait()\n\n\t\t// Verify all suites were created and have correct result counts\n\t\tstatuses, err := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get all suite statuses: %v\", err)\n\t\t}\n\t\tif len(statuses) != numGoroutines {\n\t\t\tt.Errorf(\"expected %d suite statuses, got %d\", numGoroutines, len(statuses))\n\t\t}\n\n\t\t// Verify each suite has the correct number of results\n\t\tfor _, status := range statuses {\n\t\t\tif len(status.Results) != resultsPerGoroutine {\n\t\t\t\tt.Errorf(\"suite %s should have %d results, got %d\", status.Name, resultsPerGoroutine, len(status.Results))\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"concurrent-mixed-operations\", func(t *testing.T) {\n\t\tvar wg sync.WaitGroup\n\n\t\t// Setup test data\n\t\tep := &endpoint.Endpoint{Name: \"mixed-endpoint\", Group: \"test\", URL: \"https://example.com\"}\n\t\ttestSuite := &suite.Suite{Name: \"mixed-suite\", Group: \"test\"}\n\n\t\t// Concurrent endpoint insertions\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor i := range 5 {\n\t\t\t\tresult := &endpoint.Result{\n\t\t\t\t\tSuccess:   true,\n\t\t\t\t\tTimestamp: time.Now(),\n\t\t\t\t\tDuration:  time.Duration(i*10) * time.Millisecond,\n\t\t\t\t}\n\t\t\t\tstore.InsertEndpointResult(ep, result)\n\t\t\t}\n\t\t}()\n\n\t\t// Concurrent suite insertions\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor i := range 5 {\n\t\t\t\tresult := &suite.Result{\n\t\t\t\t\tName:      testSuite.Name,\n\t\t\t\t\tGroup:     testSuite.Group,\n\t\t\t\t\tSuccess:   true,\n\t\t\t\t\tTimestamp: time.Now(),\n\t\t\t\t\tDuration:  time.Duration(i*20) * time.Millisecond,\n\t\t\t\t}\n\t\t\t\tstore.InsertSuiteResult(testSuite, result)\n\t\t\t}\n\t\t}()\n\n\t\t// Concurrent reads\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor range 10 {\n\t\t\t\tstore.GetAllEndpointStatuses(&paging.EndpointStatusParams{})\n\t\t\t\tstore.GetAllSuiteStatuses(&paging.SuiteStatusParams{})\n\t\t\t\ttime.Sleep(1 * time.Millisecond)\n\t\t\t}\n\t\t}()\n\n\t\twg.Wait()\n\n\t\t// Verify final state is consistent\n\t\tendpointStatuses, err := store.GetAllEndpointStatuses(&paging.EndpointStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get endpoint statuses after concurrent operations: %v\", err)\n\t\t}\n\t\tif len(endpointStatuses) == 0 {\n\t\t\tt.Error(\"expected at least one endpoint status after concurrent operations\")\n\t\t}\n\n\t\tsuiteStatuses, err := store.GetAllSuiteStatuses(&paging.SuiteStatusParams{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to get suite statuses after concurrent operations: %v\", err)\n\t\t}\n\t\tif len(suiteStatuses) == 0 {\n\t\t\tt.Error(\"expected at least one suite status after concurrent operations\")\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "storage/store/memory/uptime.go",
    "content": "package memory\n\nimport (\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\nconst (\n\tuptimeCleanUpThreshold = 32 * 24\n\tuptimeRetention        = 30 * 24 * time.Hour\n)\n\n// processUptimeAfterResult processes the result by extracting the relevant from the result and recalculating the uptime\n// if necessary\nfunc processUptimeAfterResult(uptime *endpoint.Uptime, result *endpoint.Result) {\n\tif uptime.HourlyStatistics == nil {\n\t\tuptime.HourlyStatistics = make(map[int64]*endpoint.HourlyUptimeStatistics)\n\t}\n\tunixTimestampFlooredAtHour := result.Timestamp.Truncate(time.Hour).Unix()\n\thourlyStats, _ := uptime.HourlyStatistics[unixTimestampFlooredAtHour]\n\tif hourlyStats == nil {\n\t\thourlyStats = &endpoint.HourlyUptimeStatistics{}\n\t\tuptime.HourlyStatistics[unixTimestampFlooredAtHour] = hourlyStats\n\t}\n\tif result.Success {\n\t\thourlyStats.SuccessfulExecutions++\n\t}\n\thourlyStats.TotalExecutions++\n\thourlyStats.TotalExecutionsResponseTime += uint64(result.Duration.Milliseconds())\n\t// Clean up only when we're starting to have too many useless keys\n\t// Note that this is only triggered when there are more entries than there should be after\n\t// 32 days, despite the fact that we are deleting everything that's older than 30 days.\n\t// This is to prevent re-iterating on every `processUptimeAfterResult` as soon as the uptime has been logged for 30 days.\n\tif len(uptime.HourlyStatistics) > uptimeCleanUpThreshold {\n\t\tsevenDaysAgo := time.Now().Add(-(uptimeRetention + time.Hour)).Unix()\n\t\tfor hourlyUnixTimestamp := range uptime.HourlyStatistics {\n\t\t\tif sevenDaysAgo > hourlyUnixTimestamp {\n\t\t\t\tdelete(uptime.HourlyStatistics, hourlyUnixTimestamp)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "storage/store/memory/uptime_bench_test.go",
    "content": "package memory\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\nfunc BenchmarkProcessUptimeAfterResult(b *testing.B) {\n\tuptime := endpoint.NewUptime()\n\tnow := time.Now()\n\tnow = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())\n\t// Start 12000 days ago\n\ttimestamp := now.Add(-12000 * 24 * time.Hour)\n\tfor n := 0; n < b.N; n++ {\n\t\tprocessUptimeAfterResult(uptime, &endpoint.Result{\n\t\t\tDuration:  18 * time.Millisecond,\n\t\t\tSuccess:   n%15 == 0,\n\t\t\tTimestamp: timestamp,\n\t\t})\n\t\t// Simulate an endpoint with an interval of 3 minutes\n\t\ttimestamp = timestamp.Add(3 * time.Minute)\n\t}\n\tb.ReportAllocs()\n}\n"
  },
  {
    "path": "storage/store/memory/uptime_test.go",
    "content": "package memory\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n)\n\nfunc TestProcessUptimeAfterResult(t *testing.T) {\n\tep := &endpoint.Endpoint{Name: \"name\", Group: \"group\"}\n\tstatus := endpoint.NewStatus(ep.Group, ep.Name)\n\tuptime := status.Uptime\n\n\tnow := time.Now()\n\tnow = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-7 * 24 * time.Hour), Success: true})\n\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-6 * 24 * time.Hour), Success: false})\n\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-8 * 24 * time.Hour), Success: true})\n\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-24 * time.Hour), Success: true})\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-12 * time.Hour), Success: true})\n\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-1 * time.Hour), Success: true, Duration: 10 * time.Millisecond})\n\tcheckHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 10, 1, 1)\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-30 * time.Minute), Success: false, Duration: 500 * time.Millisecond})\n\tcheckHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 510, 2, 1)\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-15 * time.Minute), Success: false, Duration: 25 * time.Millisecond})\n\tcheckHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 535, 3, 1)\n\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-10 * time.Minute), Success: false})\n\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-120 * time.Hour), Success: true})\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-119 * time.Hour), Success: true})\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-118 * time.Hour), Success: true})\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-117 * time.Hour), Success: true})\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-10 * time.Hour), Success: true})\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-8 * time.Hour), Success: true})\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-30 * time.Minute), Success: true})\n\tprocessUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-25 * time.Minute), Success: true})\n}\n\nfunc TestAddResultUptimeIsCleaningUpAfterItself(t *testing.T) {\n\tep := &endpoint.Endpoint{Name: \"name\", Group: \"group\"}\n\tstatus := endpoint.NewStatus(ep.Group, ep.Name)\n\tnow := time.Now()\n\tnow = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())\n\t// Start 12 days ago\n\ttimestamp := now.Add(-12 * 24 * time.Hour)\n\tfor timestamp.Unix() <= now.Unix() {\n\t\tAddResult(status, &endpoint.Result{Timestamp: timestamp, Success: true}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\t\tif len(status.Uptime.HourlyStatistics) > uptimeCleanUpThreshold {\n\t\t\tt.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))\n\t\t}\n\t\t// Simulate endpoint with an interval of 3 minutes\n\t\ttimestamp = timestamp.Add(3 * time.Minute)\n\t}\n}\n\nfunc checkHourlyStatistics(t *testing.T, hourlyUptimeStatistics *endpoint.HourlyUptimeStatistics, expectedTotalExecutionsResponseTime uint64, expectedTotalExecutions uint64, expectedSuccessfulExecutions uint64) {\n\tif hourlyUptimeStatistics.TotalExecutionsResponseTime != expectedTotalExecutionsResponseTime {\n\t\tt.Error(\"TotalExecutionsResponseTime should've been\", expectedTotalExecutionsResponseTime, \"got\", hourlyUptimeStatistics.TotalExecutionsResponseTime)\n\t}\n\tif hourlyUptimeStatistics.TotalExecutions != expectedTotalExecutions {\n\t\tt.Error(\"TotalExecutions should've been\", expectedTotalExecutions, \"got\", hourlyUptimeStatistics.TotalExecutions)\n\t}\n\tif hourlyUptimeStatistics.SuccessfulExecutions != expectedSuccessfulExecutions {\n\t\tt.Error(\"SuccessfulExecutions should've been\", expectedSuccessfulExecutions, \"got\", hourlyUptimeStatistics.SuccessfulExecutions)\n\t}\n}\n"
  },
  {
    "path": "storage/store/memory/util.go",
    "content": "package memory\n\nimport (\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n)\n\n// ShallowCopyEndpointStatus returns a shallow copy of a Status with only the results\n// within the range defined by the page and pageSize parameters\nfunc ShallowCopyEndpointStatus(ss *endpoint.Status, params *paging.EndpointStatusParams) *endpoint.Status {\n\tshallowCopy := &endpoint.Status{\n\t\tName:   ss.Name,\n\t\tGroup:  ss.Group,\n\t\tKey:    ss.Key,\n\t\tUptime: endpoint.NewUptime(),\n\t}\n\tif params == nil || (params.ResultsPage == 0 && params.ResultsPageSize == 0 && params.EventsPage == 0 && params.EventsPageSize == 0) {\n\t\tshallowCopy.Results = ss.Results\n\t\tshallowCopy.Events = ss.Events\n\t} else {\n\t\tnumberOfResults := len(ss.Results)\n\t\tresultsStart, resultsEnd := getStartAndEndIndex(numberOfResults, params.ResultsPage, params.ResultsPageSize)\n\t\tif resultsStart < 0 || resultsEnd < 0 {\n\t\t\tshallowCopy.Results = []*endpoint.Result{}\n\t\t} else {\n\t\t\tshallowCopy.Results = ss.Results[resultsStart:resultsEnd]\n\t\t}\n\t\tnumberOfEvents := len(ss.Events)\n\t\teventsStart, eventsEnd := getStartAndEndIndex(numberOfEvents, params.EventsPage, params.EventsPageSize)\n\t\tif eventsStart < 0 || eventsEnd < 0 {\n\t\t\tshallowCopy.Events = []*endpoint.Event{}\n\t\t} else {\n\t\t\tshallowCopy.Events = ss.Events[eventsStart:eventsEnd]\n\t\t}\n\t}\n\treturn shallowCopy\n}\n\n// ShallowCopySuiteStatus returns a shallow copy of a suite Status with only the results\n// within the range defined by the page and pageSize parameters\nfunc ShallowCopySuiteStatus(ss *suite.Status, params *paging.SuiteStatusParams) *suite.Status {\n\tshallowCopy := &suite.Status{\n\t\tName:  ss.Name,\n\t\tGroup: ss.Group,\n\t\tKey:   ss.Key,\n\t}\n\tif params == nil || (params.Page == 0 && params.PageSize == 0) {\n\t\tshallowCopy.Results = ss.Results\n\t} else {\n\t\tnumberOfResults := len(ss.Results)\n\t\tresultsStart, resultsEnd := getStartAndEndIndex(numberOfResults, params.Page, params.PageSize)\n\t\tif resultsStart < 0 || resultsEnd < 0 {\n\t\t\tshallowCopy.Results = []*suite.Result{}\n\t\t} else {\n\t\t\tshallowCopy.Results = ss.Results[resultsStart:resultsEnd]\n\t\t}\n\t}\n\treturn shallowCopy\n}\n\nfunc getStartAndEndIndex(numberOfResults int, page, pageSize int) (int, int) {\n\tif page < 1 || pageSize < 0 {\n\t\treturn -1, -1\n\t}\n\tstart := numberOfResults - (page * pageSize)\n\tend := numberOfResults - ((page - 1) * pageSize)\n\tif start > numberOfResults {\n\t\tstart = -1\n\t} else if start < 0 {\n\t\tstart = 0\n\t}\n\tif end > numberOfResults {\n\t\tend = numberOfResults\n\t}\n\treturn start, end\n}\n\n// AddResult adds a Result to Status.Results and makes sure that there are\n// no more than MaximumNumberOfResults results in the Results slice\nfunc AddResult(ss *endpoint.Status, result *endpoint.Result, maximumNumberOfResults, maximumNumberOfEvents int) {\n\tif ss == nil {\n\t\treturn\n\t}\n\tif len(ss.Results) > 0 {\n\t\t// Check if there's any change since the last result\n\t\tif ss.Results[len(ss.Results)-1].Success != result.Success {\n\t\t\tss.Events = append(ss.Events, endpoint.NewEventFromResult(result))\n\t\t\tif len(ss.Events) > maximumNumberOfEvents {\n\t\t\t\t// Doing ss.Events[1:] would usually be sufficient, but in the case where for some reason, the slice has\n\t\t\t\t// more than one extra element, we can get rid of all of them at once and thus returning the slice to a\n\t\t\t\t// length of MaximumNumberOfEvents by using ss.Events[len(ss.Events)-MaximumNumberOfEvents:] instead\n\t\t\t\tss.Events = ss.Events[len(ss.Events)-maximumNumberOfEvents:]\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// This is the first result, so we need to add the first healthy/unhealthy event\n\t\tss.Events = append(ss.Events, endpoint.NewEventFromResult(result))\n\t}\n\tss.Results = append(ss.Results, result)\n\tif len(ss.Results) > maximumNumberOfResults {\n\t\t// Doing ss.Results[1:] would usually be sufficient, but in the case where for some reason, the slice has more\n\t\t// than one extra element, we can get rid of all of them at once and thus returning the slice to a length of\n\t\t// MaximumNumberOfResults by using ss.Results[len(ss.Results)-MaximumNumberOfResults:] instead\n\t\tss.Results = ss.Results[len(ss.Results)-maximumNumberOfResults:]\n\t}\n\tprocessUptimeAfterResult(ss.Uptime, result)\n}\n"
  },
  {
    "path": "storage/store/memory/util_bench_test.go",
    "content": "package memory\n\nimport (\n\t\"testing\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n)\n\nfunc BenchmarkShallowCopyEndpointStatus(b *testing.B) {\n\tep := &testEndpoint\n\tstatus := endpoint.NewStatus(ep.Group, ep.Name)\n\tfor range storage.DefaultMaximumNumberOfResults {\n\t\tAddResult(status, &testSuccessfulResult, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\t}\n\tfor b.Loop() {\n\t\tShallowCopyEndpointStatus(status, paging.NewEndpointStatusParams().WithResults(1, 20))\n\t}\n\tb.ReportAllocs()\n}\n"
  },
  {
    "path": "storage/store/memory/util_test.go",
    "content": "package memory\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n)\n\nfunc TestAddResult(t *testing.T) {\n\tep := &endpoint.Endpoint{Name: \"name\", Group: \"group\"}\n\tendpointStatus := endpoint.NewStatus(ep.Group, ep.Name)\n\tfor i := range (storage.DefaultMaximumNumberOfResults + storage.DefaultMaximumNumberOfEvents) * 2 {\n\t\tAddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\t}\n\tif len(endpointStatus.Results) != storage.DefaultMaximumNumberOfResults {\n\t\tt.Errorf(\"expected endpointStatus.Results to not exceed a length of %d\", storage.DefaultMaximumNumberOfResults)\n\t}\n\tif len(endpointStatus.Events) != storage.DefaultMaximumNumberOfEvents {\n\t\tt.Errorf(\"expected endpointStatus.Events to not exceed a length of %d\", storage.DefaultMaximumNumberOfEvents)\n\t}\n\t// Try to add nil endpointStatus\n\tAddResult(nil, &endpoint.Result{Timestamp: time.Now()}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n}\n\nfunc TestShallowCopyEndpointStatus(t *testing.T) {\n\tep := &endpoint.Endpoint{Name: \"name\", Group: \"group\"}\n\tendpointStatus := endpoint.NewStatus(ep.Group, ep.Name)\n\tts := time.Now().Add(-25 * time.Hour)\n\tfor i := range 25 {\n\t\tAddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: ts}, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\t\tts = ts.Add(time.Hour)\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(-1, -1)).Results) != 0 {\n\t\tt.Error(\"expected to have 0 result\")\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(1, 1)).Results) != 1 {\n\t\tt.Error(\"expected to have 1 result\")\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(5, 0)).Results) != 0 {\n\t\tt.Error(\"expected to have 0 results\")\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(-1, 20)).Results) != 0 {\n\t\tt.Error(\"expected to have 0 result, because the page was invalid\")\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(1, -1)).Results) != 0 {\n\t\tt.Error(\"expected to have 0 result, because the page size was invalid\")\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(1, 10)).Results) != 10 {\n\t\tt.Error(\"expected to have 10 results, because given a page size of 10, page 1 should have 10 elements\")\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(2, 10)).Results) != 10 {\n\t\tt.Error(\"expected to have 10 results, because given a page size of 10, page 2 should have 10 elements\")\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(3, 10)).Results) != 5 {\n\t\tt.Error(\"expected to have 5 results, because given a page size of 10, page 3 should have 5 elements\")\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(4, 10)).Results) != 0 {\n\t\tt.Error(\"expected to have 0 results, because given a page size of 10, page 4 should have 0 elements\")\n\t}\n\tif len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(1, 50)).Results) != 25 {\n\t\tt.Error(\"expected to have 25 results, because there's only 25 results\")\n\t}\n}\n\nfunc TestShallowCopySuiteStatus(t *testing.T) {\n\ttestSuite := &suite.Suite{Name: \"test-suite\", Group: \"test-group\"}\n\tsuiteStatus := &suite.Status{\n\t\tName:    testSuite.Name,\n\t\tGroup:   testSuite.Group,\n\t\tKey:     testSuite.Key(),\n\t\tResults: []*suite.Result{},\n\t}\n\n\tts := time.Now().Add(-25 * time.Hour)\n\tfor i := range 25 {\n\t\tresult := &suite.Result{\n\t\t\tName:      testSuite.Name,\n\t\t\tGroup:     testSuite.Group,\n\t\t\tSuccess:   i%2 == 0,\n\t\t\tTimestamp: ts,\n\t\t\tDuration:  time.Duration(i*10) * time.Millisecond,\n\t\t}\n\t\tsuiteStatus.Results = append(suiteStatus.Results, result)\n\t\tts = ts.Add(time.Hour)\n\t}\n\n\tt.Run(\"invalid-page-negative\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(-1, 10))\n\t\tif len(result.Results) != 0 {\n\t\t\tt.Errorf(\"expected 0 results for negative page, got %d\", len(result.Results))\n\t\t}\n\t})\n\n\tt.Run(\"invalid-page-zero\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(0, 10))\n\t\tif len(result.Results) != 0 {\n\t\t\tt.Errorf(\"expected 0 results for zero page, got %d\", len(result.Results))\n\t\t}\n\t})\n\n\tt.Run(\"invalid-pagesize-negative\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(1, -1))\n\t\tif len(result.Results) != 0 {\n\t\t\tt.Errorf(\"expected 0 results for negative page size, got %d\", len(result.Results))\n\t\t}\n\t})\n\n\tt.Run(\"zero-pagesize\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(1, 0))\n\t\tif len(result.Results) != 0 {\n\t\t\tt.Errorf(\"expected 0 results for zero page size, got %d\", len(result.Results))\n\t\t}\n\t})\n\n\tt.Run(\"nil-params\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, nil)\n\t\tif len(result.Results) != 25 {\n\t\t\tt.Errorf(\"expected 25 results for nil params, got %d\", len(result.Results))\n\t\t}\n\t})\n\n\tt.Run(\"zero-params\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, &paging.SuiteStatusParams{Page: 0, PageSize: 0})\n\t\tif len(result.Results) != 25 {\n\t\t\tt.Errorf(\"expected 25 results for zero-value params, got %d\", len(result.Results))\n\t\t}\n\t})\n\n\tt.Run(\"first-page\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(1, 10))\n\t\tif len(result.Results) != 10 {\n\t\t\tt.Errorf(\"expected 10 results for page 1, size 10, got %d\", len(result.Results))\n\t\t}\n\t\t// Verify newest results are returned (reverse pagination)\n\t\tif len(result.Results) > 0 && !result.Results[len(result.Results)-1].Timestamp.After(result.Results[0].Timestamp) {\n\t\t\tt.Error(\"expected newest result to be at the end\")\n\t\t}\n\t})\n\n\tt.Run(\"second-page\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(2, 10))\n\t\tif len(result.Results) != 10 {\n\t\t\tt.Errorf(\"expected 10 results for page 2, size 10, got %d\", len(result.Results))\n\t\t}\n\t})\n\n\tt.Run(\"last-partial-page\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(3, 10))\n\t\tif len(result.Results) != 5 {\n\t\t\tt.Errorf(\"expected 5 results for page 3, size 10, got %d\", len(result.Results))\n\t\t}\n\t})\n\n\tt.Run(\"beyond-available-pages\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(4, 10))\n\t\tif len(result.Results) != 0 {\n\t\t\tt.Errorf(\"expected 0 results for page beyond available data, got %d\", len(result.Results))\n\t\t}\n\t})\n\n\tt.Run(\"large-page-size\", func(t *testing.T) {\n\t\tresult := ShallowCopySuiteStatus(suiteStatus, paging.NewSuiteStatusParams().WithPagination(1, 100))\n\t\tif len(result.Results) != 25 {\n\t\t\tt.Errorf(\"expected 25 results for large page size, got %d\", len(result.Results))\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "storage/store/sql/specific_postgres.go",
    "content": "package sql\n\nfunc (s *Store) createPostgresSchema() error {\n\t// Create suite tables\n\t_, err := s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS suites (\n\t\t\tsuite_id    BIGSERIAL PRIMARY KEY,\n\t\t\tsuite_key   TEXT UNIQUE,\n\t\t\tsuite_name  TEXT NOT NULL,\n\t\t\tsuite_group TEXT NOT NULL,\n\t\t\tUNIQUE(suite_name, suite_group)\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS suite_results (\n\t\t\tsuite_result_id  BIGSERIAL PRIMARY KEY,\n\t\t\tsuite_id         BIGINT    NOT NULL REFERENCES suites(suite_id) ON DELETE CASCADE,\n\t\t\tsuccess          BOOLEAN   NOT NULL,\n\t\t\terrors           TEXT      NOT NULL,\n\t\t\tduration         BIGINT    NOT NULL,\n\t\t\ttimestamp        TIMESTAMP NOT NULL\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Create endpoint tables\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoints (\n\t\t\tendpoint_id    BIGSERIAL PRIMARY KEY,\n\t\t\tendpoint_key   TEXT UNIQUE,\n\t\t\tendpoint_name  TEXT NOT NULL,\n\t\t\tendpoint_group TEXT NOT NULL,\n\t\t\tUNIQUE(endpoint_name, endpoint_group)\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_events (\n\t\t\tendpoint_event_id  BIGSERIAL PRIMARY KEY,\n\t\t\tendpoint_id        BIGINT    NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,\n\t\t\tevent_type         TEXT      NOT NULL,\n\t\t\tevent_timestamp    TIMESTAMP NOT NULL\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_results (\n\t\t\tendpoint_result_id     BIGSERIAL PRIMARY KEY,\n\t\t\tendpoint_id            BIGINT    NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,\n\t\t\tsuccess                BOOLEAN   NOT NULL,\n\t\t\terrors                 TEXT      NOT NULL,\n\t\t\tconnected              BOOLEAN   NOT NULL,\n\t\t\tstatus                 BIGINT    NOT NULL,\n\t\t\tdns_rcode              TEXT      NOT NULL,\n\t\t\tcertificate_expiration BIGINT    NOT NULL,\n\t\t\tdomain_expiration      BIGINT    NOT NULL,\n\t\t\thostname               TEXT      NOT NULL,\n\t\t\tip                     TEXT      NOT NULL,\n\t\t\tduration               BIGINT    NOT NULL,\n\t\t\ttimestamp              TIMESTAMP NOT NULL,\n\t\t\tsuite_result_id        BIGINT    REFERENCES suite_results(suite_result_id) ON DELETE CASCADE\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_result_conditions (\n\t\t\tendpoint_result_condition_id  BIGSERIAL PRIMARY KEY,\n\t\t\tendpoint_result_id            BIGINT  NOT NULL REFERENCES endpoint_results(endpoint_result_id) ON DELETE CASCADE,\n\t\t\tcondition                     TEXT    NOT NULL,\n\t\t\tsuccess                       BOOLEAN NOT NULL\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_uptimes (\n\t\t\tendpoint_uptime_id     BIGSERIAL PRIMARY KEY,\n\t\t\tendpoint_id            BIGINT NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,\n\t\t\thour_unix_timestamp    BIGINT NOT NULL,\n\t\t\ttotal_executions       BIGINT NOT NULL,\n\t\t\tsuccessful_executions  BIGINT NOT NULL,\n\t\t\ttotal_response_time    BIGINT NOT NULL,\n\t\t\tUNIQUE(endpoint_id, hour_unix_timestamp)\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_alerts_triggered (\n\t\t\tendpoint_alert_trigger_id     BIGSERIAL PRIMARY KEY,\n\t\t\tendpoint_id                   BIGINT    NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,\n\t\t    configuration_checksum        TEXT      NOT NULL,\n\t\t    resolve_key\t\t              TEXT      NOT NULL,\n\t\t\tnumber_of_successes_in_a_row  INTEGER   NOT NULL,\n\t\t\tUNIQUE(endpoint_id, configuration_checksum)\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Create index for suite_results\n\t_, err = s.db.Exec(`\n\t\tCREATE INDEX IF NOT EXISTS suite_results_suite_id_idx ON suite_results (suite_id);\n\t`)\n\t// Silent table modifications TODO: Remove this in v6.0.0\n\t_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD IF NOT EXISTS domain_expiration BIGINT NOT NULL DEFAULT 0`)\n\t// Add suite_result_id to endpoint_results table for suite endpoint linkage\n\t_, _ = 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`)\n\t// Create index for suite_result_id\n\t_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS endpoint_results_suite_result_id_idx ON endpoint_results(suite_result_id)`)\n\t// Create index for endpoint_result_conditions\n\t_, _ = s.db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_endpoint_result_conditions_endpoint_result_id ON endpoint_result_conditions (endpoint_result_id)`)\n\t// Create index for endpoint_results\n\t_, _ = s.db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_endpoint_results_endpoint_id ON endpoint_results (endpoint_id)`)\n\treturn err\n}\n"
  },
  {
    "path": "storage/store/sql/specific_sqlite.go",
    "content": "package sql\n\nfunc (s *Store) createSQLiteSchema() error {\n\t// Create suite tables\n\t_, err := s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS suites (\n\t\t\tsuite_id    INTEGER PRIMARY KEY,\n\t\t\tsuite_key   TEXT UNIQUE,\n\t\t\tsuite_name  TEXT NOT NULL,\n\t\t\tsuite_group TEXT NOT NULL,\n\t\t\tUNIQUE(suite_name, suite_group)\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS suite_results (\n\t\t\tsuite_result_id  INTEGER PRIMARY KEY,\n\t\t\tsuite_id         INTEGER   NOT NULL REFERENCES suites(suite_id) ON DELETE CASCADE,\n\t\t\tsuccess          INTEGER   NOT NULL,\n\t\t\terrors           TEXT      NOT NULL,\n\t\t\tduration         INTEGER   NOT NULL,\n\t\t\ttimestamp        TIMESTAMP NOT NULL\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Create endpoint tables\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoints (\n\t\t\tendpoint_id    INTEGER PRIMARY KEY,\n\t\t\tendpoint_key   TEXT UNIQUE,\n\t\t\tendpoint_name  TEXT NOT NULL,\n\t\t\tendpoint_group TEXT NOT NULL,\n\t\t\tUNIQUE(endpoint_name, endpoint_group)\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_events (\n\t\t\tendpoint_event_id  INTEGER PRIMARY KEY,\n\t\t\tendpoint_id        INTEGER   NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,\n\t\t\tevent_type         TEXT      NOT NULL,\n\t\t\tevent_timestamp    TIMESTAMP NOT NULL\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_results (\n\t\t\tendpoint_result_id     INTEGER PRIMARY KEY,\n\t\t\tendpoint_id            INTEGER   NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,\n\t\t\tsuccess                INTEGER   NOT NULL,\n\t\t\terrors                 TEXT      NOT NULL,\n\t\t\tconnected              INTEGER   NOT NULL,\n\t\t\tstatus                 INTEGER   NOT NULL,\n\t\t\tdns_rcode              TEXT      NOT NULL,\n\t\t\tcertificate_expiration INTEGER   NOT NULL,\n\t\t    domain_expiration      INTEGER   NOT NULL,\n\t\t\thostname               TEXT      NOT NULL,\n\t\t\tip                     TEXT      NOT NULL,\n\t\t\tduration               INTEGER   NOT NULL,\n\t\t\ttimestamp              TIMESTAMP NOT NULL,\n\t\t\tsuite_result_id        INTEGER   REFERENCES suite_results(suite_result_id) ON DELETE CASCADE\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_result_conditions (\n\t\t\tendpoint_result_condition_id  INTEGER PRIMARY KEY,\n\t\t\tendpoint_result_id            INTEGER NOT NULL REFERENCES endpoint_results(endpoint_result_id) ON DELETE CASCADE,\n\t\t\tcondition                     TEXT    NOT NULL,\n\t\t\tsuccess                       INTEGER NOT NULL\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_uptimes (\n\t\t\tendpoint_uptime_id    INTEGER PRIMARY KEY,\n\t\t\tendpoint_id           INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,\n\t\t\thour_unix_timestamp   INTEGER NOT NULL,\n\t\t\ttotal_executions      INTEGER NOT NULL,\n\t\t\tsuccessful_executions INTEGER NOT NULL,\n\t\t\ttotal_response_time   INTEGER NOT NULL,\n\t\t\tUNIQUE(endpoint_id, hour_unix_timestamp)\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS endpoint_alerts_triggered (\n\t\t\tendpoint_alert_trigger_id     INTEGER PRIMARY KEY,\n\t\t\tendpoint_id                   INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,\n\t\t    configuration_checksum        TEXT    NOT NULL,\n\t\t    resolve_key\t\t              TEXT    NOT NULL,\n\t\t\tnumber_of_successes_in_a_row  INTEGER NOT NULL,\n\t\t\tUNIQUE(endpoint_id, configuration_checksum)\n\t\t)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Create indices for performance reasons\n\t_, err = s.db.Exec(`\n\t\tCREATE INDEX IF NOT EXISTS endpoint_results_endpoint_id_idx ON endpoint_results (endpoint_id);\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE INDEX IF NOT EXISTS endpoint_uptimes_endpoint_id_idx ON endpoint_uptimes (endpoint_id);\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = s.db.Exec(`\n\t\tCREATE INDEX IF NOT EXISTS endpoint_result_conditions_endpoint_result_id_idx ON endpoint_result_conditions (endpoint_result_id);\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Create index for suite_results\n\t_, err = s.db.Exec(`\n\t\tCREATE INDEX IF NOT EXISTS suite_results_suite_id_idx ON suite_results (suite_id);\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Silent table modifications TODO: Remove this in v6.0.0\n\t_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD domain_expiration INTEGER NOT NULL DEFAULT 0`)\n\t// Add suite_result_id to endpoint_results table for suite endpoint linkage\n\t_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD suite_result_id INTEGER REFERENCES suite_results(suite_result_id) ON DELETE CASCADE`)\n\t// Create index for suite_result_id\n\t_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS endpoint_results_suite_result_id_idx ON endpoint_results(suite_result_id)`)\n\t// Note: SQLite doesn't support DROP COLUMN in older versions, so we skip this cleanup\n\t// The suite_id column in endpoints table will remain but unused\n\treturn err\n}\n"
  },
  {
    "path": "storage/store/sql/sql.go",
    "content": "package sql\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/key\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n\t\"github.com/TwiN/gocache/v2\"\n\t\"github.com/TwiN/logr\"\n\t_ \"github.com/lib/pq\"\n\t_ \"modernc.org/sqlite\"\n)\n\n//////////////////////////////////////////////////////////////////////////////////////////////////\n// Note that only exported functions in this file may create, commit, or rollback a transaction //\n//////////////////////////////////////////////////////////////////////////////////////////////////\n\nconst (\n\t// arraySeparator is the separator used to separate multiple strings in a single column.\n\t// It's a dirty hack, but it's only used for persisting errors, and since this data will likely only ever be used\n\t// for aesthetic purposes, I deemed it wasn't worth the performance impact of yet another one-to-many table.\n\tarraySeparator = \"|~|\"\n\n\teventsAboveMaximumCleanUpThreshold  = 10 // Maximum number of events above the configured maximum before triggering a cleanup\n\tresultsAboveMaximumCleanUpThreshold = 10 // Maximum number of results above the configured maximum before triggering a cleanup\n\n\tuptimeTotalEntriesMergeThreshold = 100                 // Maximum number of uptime entries before triggering a merge\n\tuptimeAgeCleanUpThreshold        = 32 * 24 * time.Hour // Maximum uptime age before triggering a cleanup\n\tuptimeRetention                  = 30 * 24 * time.Hour // Minimum duration that must be kept to operate as intended\n\tuptimeHourlyBuffer               = 48 * time.Hour      // Number of hours to buffer from now when determining which hourly uptime entries can be merged into daily uptime entries\n\n\tcacheTTL = 10 * time.Minute\n)\n\nvar (\n\t// ErrPathNotSpecified is the error returned when the path parameter passed in NewStore is blank\n\tErrPathNotSpecified = errors.New(\"path cannot be empty\")\n\n\t// ErrDatabaseDriverNotSpecified is the error returned when the driver parameter passed in NewStore is blank\n\tErrDatabaseDriverNotSpecified = errors.New(\"database driver cannot be empty\")\n\n\terrNoRowsReturned = errors.New(\"expected a row to be returned, but none was\")\n)\n\n// Store that leverages a database\ntype Store struct {\n\tdriver, path string\n\n\tdb *sql.DB\n\n\t// writeThroughCache is a cache used to drastically decrease read latency by pre-emptively\n\t// caching writes as they happen. If nil, writes are not cached.\n\twriteThroughCache *gocache.Cache\n\n\tmaximumNumberOfResults int // maximum number of results that an endpoint can have\n\tmaximumNumberOfEvents  int // maximum number of events that an endpoint can have\n}\n\n// NewStore initializes the database and creates the schema if it doesn't already exist in the path specified\nfunc NewStore(driver, path string, caching bool, maximumNumberOfResults, maximumNumberOfEvents int) (*Store, error) {\n\tif len(driver) == 0 {\n\t\treturn nil, ErrDatabaseDriverNotSpecified\n\t}\n\tif len(path) == 0 {\n\t\treturn nil, ErrPathNotSpecified\n\t}\n\tstore := &Store{\n\t\tdriver:                 driver,\n\t\tpath:                   path,\n\t\tmaximumNumberOfResults: maximumNumberOfResults,\n\t\tmaximumNumberOfEvents:  maximumNumberOfEvents,\n\t}\n\tvar err error\n\tif store.db, err = sql.Open(driver, path); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := store.db.Ping(); err != nil {\n\t\treturn nil, err\n\t}\n\tif driver == \"sqlite\" {\n\t\t_, _ = store.db.Exec(\"PRAGMA foreign_keys=ON\")\n\t\t_, _ = store.db.Exec(\"PRAGMA journal_mode=WAL\")\n\t\t_, _ = store.db.Exec(\"PRAGMA synchronous=NORMAL\")\n\t\t// Prevents driver from running into \"database is locked\" errors\n\t\t// This is because we're using WAL to improve performance\n\t\tstore.db.SetMaxOpenConns(1)\n\t}\n\tif err = store.createSchema(); err != nil {\n\t\t_ = store.db.Close()\n\t\treturn nil, err\n\t}\n\tif caching {\n\t\tstore.writeThroughCache = gocache.NewCache().WithMaxSize(10000)\n\t}\n\treturn store, nil\n}\n\n// createSchema creates the schema required to perform all database operations.\nfunc (s *Store) createSchema() error {\n\tif s.driver == \"sqlite\" {\n\t\treturn s.createSQLiteSchema()\n\t}\n\treturn s.createPostgresSchema()\n}\n\n// GetAllEndpointStatuses returns all monitored endpoint.Status\n// with a subset of endpoint.Result defined by the page and pageSize parameters\nfunc (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error) {\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tkeys, err := s.getAllEndpointKeys(tx)\n\tif err != nil {\n\t\t_ = tx.Rollback()\n\t\treturn nil, err\n\t}\n\tendpointStatuses := make([]*endpoint.Status, 0, len(keys))\n\tfor _, key := range keys {\n\t\tendpointStatus, err := s.getEndpointStatusByKey(tx, key, params)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tendpointStatuses = append(endpointStatuses, endpointStatus)\n\t}\n\tif err = tx.Commit(); err != nil {\n\t\t_ = tx.Rollback()\n\t}\n\treturn endpointStatuses, err\n}\n\n// GetEndpointStatus returns the endpoint status for a given endpoint name in the given group\nfunc (s *Store) GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*endpoint.Status, error) {\n\treturn s.GetEndpointStatusByKey(key.ConvertGroupAndNameToKey(groupName, endpointName), params)\n}\n\n// GetEndpointStatusByKey returns the endpoint status for a given key\nfunc (s *Store) GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error) {\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tendpointStatus, err := s.getEndpointStatusByKey(tx, key, params)\n\tif err != nil {\n\t\t_ = tx.Rollback()\n\t\treturn nil, err\n\t}\n\tif err = tx.Commit(); err != nil {\n\t\t_ = tx.Rollback()\n\t}\n\treturn endpointStatus, err\n}\n\n// GetUptimeByKey returns the uptime percentage during a time range\nfunc (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) {\n\tif from.After(to) {\n\t\treturn 0, common.ErrInvalidTimeRange\n\t}\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tendpointID, _, _, err := s.getEndpointIDGroupAndNameByKey(tx, key)\n\tif err != nil {\n\t\t_ = tx.Rollback()\n\t\treturn 0, err\n\t}\n\tuptime, _, err := s.getEndpointUptime(tx, endpointID, from, to)\n\tif err != nil {\n\t\t_ = tx.Rollback()\n\t\treturn 0, err\n\t}\n\tif err = tx.Commit(); err != nil {\n\t\t_ = tx.Rollback()\n\t}\n\treturn uptime, nil\n}\n\n// GetAverageResponseTimeByKey returns the average response time in milliseconds (value) during a time range\nfunc (s *Store) GetAverageResponseTimeByKey(key string, from, to time.Time) (int, error) {\n\tif from.After(to) {\n\t\treturn 0, common.ErrInvalidTimeRange\n\t}\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tendpointID, _, _, err := s.getEndpointIDGroupAndNameByKey(tx, key)\n\tif err != nil {\n\t\t_ = tx.Rollback()\n\t\treturn 0, err\n\t}\n\taverageResponseTime, err := s.getEndpointAverageResponseTime(tx, endpointID, from, to)\n\tif err != nil {\n\t\t_ = tx.Rollback()\n\t\treturn 0, err\n\t}\n\tif err = tx.Commit(); err != nil {\n\t\t_ = tx.Rollback()\n\t}\n\treturn averageResponseTime, nil\n}\n\n// GetHourlyAverageResponseTimeByKey returns a map of hourly (key) average response time in milliseconds (value) during a time range\nfunc (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error) {\n\tif from.After(to) {\n\t\treturn nil, common.ErrInvalidTimeRange\n\t}\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tendpointID, _, _, err := s.getEndpointIDGroupAndNameByKey(tx, key)\n\tif err != nil {\n\t\t_ = tx.Rollback()\n\t\treturn nil, err\n\t}\n\thourlyAverageResponseTimes, err := s.getEndpointHourlyAverageResponseTimes(tx, endpointID, from, to)\n\tif err != nil {\n\t\t_ = tx.Rollback()\n\t\treturn nil, err\n\t}\n\tif err = tx.Commit(); err != nil {\n\t\t_ = tx.Rollback()\n\t}\n\treturn hourlyAverageResponseTimes, nil\n}\n\n// InsertEndpointResult adds the observed result for the specified endpoint into the store\nfunc (s *Store) InsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Result) error {\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tendpointID, err := s.getEndpointID(tx, ep)\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\t// Endpoint doesn't exist in the database, insert it\n\t\t\tif endpointID, err = s.insertEndpoint(tx, ep); err != nil {\n\t\t\t\t_ = tx.Rollback()\n\t\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to create endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else {\n\t\t\t_ = tx.Rollback()\n\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to retrieve id of endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\treturn err\n\t\t}\n\t}\n\t// First, we need to check if we need to insert a new event.\n\t//\n\t// A new event must be added if either of the following cases happen:\n\t// 1. There is only 1 event. The total number of events for an endpoint can only be 1 if the only existing event is\n\t//    of type EventStart, in which case we will have to create a new event of type EventHealthy or EventUnhealthy\n\t//    based on result.Success.\n\t// 2. The lastResult.Success != result.Success. This implies that the endpoint went from healthy to unhealthy or\n\t//    vice versa, in which case we will have to create a new event of type EventHealthy or EventUnhealthy\n\t//\t  based on result.Success.\n\tnumberOfEvents, err := s.getNumberOfEventsByEndpointID(tx, endpointID)\n\tif err != nil {\n\t\t// Silently fail\n\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to retrieve total number of events for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t}\n\tif numberOfEvents == 0 {\n\t\t// There's no events yet, which means we need to add the EventStart and the first healthy/unhealthy event\n\t\terr = s.insertEndpointEvent(tx, endpointID, &endpoint.Event{\n\t\t\tType:      endpoint.EventStart,\n\t\t\tTimestamp: result.Timestamp.Add(-50 * time.Millisecond),\n\t\t})\n\t\tif err != nil {\n\t\t\t// Silently fail\n\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to insert event=%s for endpoint with key=%s: %s\", endpoint.EventStart, ep.Key(), err.Error())\n\t\t}\n\t\tevent := endpoint.NewEventFromResult(result)\n\t\tif err = s.insertEndpointEvent(tx, endpointID, event); err != nil {\n\t\t\t// Silently fail\n\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to insert event=%s for endpoint with key=%s: %s\", event.Type, ep.Key(), err.Error())\n\t\t}\n\t} else {\n\t\t// Get the success value of the previous result\n\t\tvar lastResultSuccess bool\n\t\tif lastResultSuccess, err = s.getLastEndpointResultSuccessValue(tx, endpointID); err != nil {\n\t\t\t// Silently fail\n\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to retrieve outcome of previous result for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t} else {\n\t\t\t// If we managed to retrieve the outcome of the previous result, we'll compare it with the new result.\n\t\t\t// If the final outcome (success or failure) of the previous and the new result aren't the same, it means\n\t\t\t// that the endpoint either went from Healthy to Unhealthy or Unhealthy -> Healthy, therefore, we'll add\n\t\t\t// an event to mark the change in state\n\t\t\tif lastResultSuccess != result.Success {\n\t\t\t\tevent := endpoint.NewEventFromResult(result)\n\t\t\t\tif err = s.insertEndpointEvent(tx, endpointID, event); err != nil {\n\t\t\t\t\t// Silently fail\n\t\t\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to insert event=%s for endpoint with key=%s: %s\", event.Type, ep.Key(), err.Error())\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Clean up old events if we're above the threshold\n\t\t// This lets us both keep the table clean without impacting performance too much\n\t\t// (since we're only deleting MaximumNumberOfEvents at a time instead of 1)\n\t\tif numberOfEvents > int64(s.maximumNumberOfEvents+eventsAboveMaximumCleanUpThreshold) {\n\t\t\tif err = s.deleteOldEndpointEvents(tx, endpointID); err != nil {\n\t\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to delete old events for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t}\n\t\t}\n\t}\n\t// Second, we need to insert the result.\n\tif err = s.insertEndpointResult(tx, endpointID, result); err != nil {\n\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to insert result for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t_ = tx.Rollback() // If we can't insert the result, we'll rollback now since there's no point continuing\n\t\treturn err\n\t}\n\t// Clean up old results\n\tnumberOfResults, err := s.getNumberOfResultsByEndpointID(tx, endpointID)\n\tif err != nil {\n\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to retrieve total number of results for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t} else {\n\t\tif numberOfResults > int64(s.maximumNumberOfResults+resultsAboveMaximumCleanUpThreshold) {\n\t\t\tif err = s.deleteOldEndpointResults(tx, endpointID); err != nil {\n\t\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to delete old results for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t}\n\t\t}\n\t}\n\t// Finally, we need to insert the uptime data.\n\t// Because the uptime data significantly outlives the results, we can't rely on the results for determining the uptime\n\tif err = s.updateEndpointUptime(tx, endpointID, result); err != nil {\n\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to update uptime for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t}\n\t// Merge hourly uptime entries that can be merged into daily entries and clean up old uptime entries\n\tnumberOfUptimeEntries, err := s.getNumberOfUptimeEntriesByEndpointID(tx, endpointID)\n\tif err != nil {\n\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to retrieve total number of uptime entries for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t} else {\n\t\t// Merge older hourly uptime entries into daily uptime entries if we have more than uptimeTotalEntriesMergeThreshold\n\t\tif numberOfUptimeEntries >= uptimeTotalEntriesMergeThreshold {\n\t\t\tlogr.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())\n\t\t\tif err = s.mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries(tx, endpointID); err != nil {\n\t\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to merge hourly uptime entries for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t}\n\t\t}\n\t}\n\t// Clean up outdated uptime entries\n\t// In most cases, this would be handled by mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries,\n\t// but if Gatus was temporarily shut down, we might have some old entries that need to be cleaned up\n\tageOfOldestUptimeEntry, err := s.getAgeOfOldestEndpointUptimeEntry(tx, endpointID)\n\tif err != nil {\n\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to retrieve oldest endpoint uptime entry for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t} else {\n\t\tif ageOfOldestUptimeEntry > uptimeAgeCleanUpThreshold {\n\t\t\tif err = s.deleteOldUptimeEntries(tx, endpointID, time.Now().Add(-(uptimeRetention + time.Hour))); err != nil {\n\t\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Failed to delete old uptime entries for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t}\n\t\t}\n\t}\n\tif s.writeThroughCache != nil {\n\t\tcacheKeysToRefresh := s.writeThroughCache.GetKeysByPattern(ep.Key()+\"*\", 0)\n\t\tfor _, cacheKey := range cacheKeysToRefresh {\n\t\t\ts.writeThroughCache.Delete(cacheKey)\n\t\t\tendpointKey, params, err := extractKeyAndParamsFromCacheKey(cacheKey)\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[sql.InsertEndpointResult] Silently deleting cache key %s instead of refreshing due to error: %s\", cacheKey, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Retrieve the endpoint status by key, which will in turn refresh the cache\n\t\t\t_, _ = s.getEndpointStatusByKey(tx, endpointKey, params)\n\t\t}\n\t}\n\tif err = tx.Commit(); err != nil {\n\t\t_ = tx.Rollback()\n\t}\n\treturn err\n}\n\n// DeleteAllEndpointStatusesNotInKeys removes all rows owned by an endpoint whose key is not within the keys provided\nfunc (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int {\n\tlogr.Debugf(\"[sql.DeleteAllEndpointStatusesNotInKeys] Called with %d keys\", len(keys))\n\tvar err error\n\tvar result sql.Result\n\tif len(keys) == 0 {\n\t\t// Delete everything\n\t\tlogr.Debugf(\"[sql.DeleteAllEndpointStatusesNotInKeys] No keys provided, deleting all endpoints\")\n\t\tresult, err = s.db.Exec(\"DELETE FROM endpoints\")\n\t} else {\n\t\t// First check what we're about to delete\n\t\targs := make([]interface{}, 0, len(keys))\n\t\tcheckQuery := \"SELECT endpoint_key FROM endpoints WHERE endpoint_key NOT IN (\"\n\t\tfor i := range keys {\n\t\t\tcheckQuery += fmt.Sprintf(\"$%d,\", i+1)\n\t\t\targs = append(args, keys[i])\n\t\t}\n\t\tcheckQuery = checkQuery[:len(checkQuery)-1] + \")\"\n\n\t\trows, checkErr := s.db.Query(checkQuery, args...)\n\t\tif checkErr == nil {\n\t\t\tdefer rows.Close()\n\t\t\tvar deletedKeys []string\n\t\t\tfor rows.Next() {\n\t\t\t\tvar key string\n\t\t\t\tif err := rows.Scan(&key); err == nil {\n\t\t\t\t\tdeletedKeys = append(deletedKeys, key)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(deletedKeys) > 0 {\n\t\t\t\tlogr.Infof(\"[sql.DeleteAllEndpointStatusesNotInKeys] Deleting endpoints with keys: %v\", deletedKeys)\n\t\t\t} else {\n\t\t\t\tlogr.Debugf(\"[sql.DeleteAllEndpointStatusesNotInKeys] No endpoints to delete\")\n\t\t\t}\n\t\t}\n\n\t\tquery := \"DELETE FROM endpoints WHERE endpoint_key NOT IN (\"\n\t\tfor i := range keys {\n\t\t\tquery += fmt.Sprintf(\"$%d,\", i+1)\n\t\t}\n\t\tquery = query[:len(query)-1] + \")\" // Remove the last comma and add the closing parenthesis\n\t\tresult, err = s.db.Exec(query, args...)\n\t}\n\tif err != nil {\n\t\tlogr.Errorf(\"[sql.DeleteAllEndpointStatusesNotInKeys] Failed to delete rows that do not belong to any of keys=%v: %s\", keys, err.Error())\n\t\treturn 0\n\t}\n\tif s.writeThroughCache != nil {\n\t\t// It's easier to just wipe out the entire cache than to try to find all keys that are not in the keys list\n\t\t// This only happens on start and during tests, so it's fine for us to just clear the cache without worrying\n\t\t// about performance\n\t\t_ = s.writeThroughCache.DeleteKeysByPattern(\"*\")\n\t}\n\t// Return number of rows deleted\n\trowsAffects, _ := result.RowsAffected()\n\treturn int(rowsAffects)\n}\n\n// GetTriggeredEndpointAlert returns whether the triggered alert for the specified endpoint as well as the necessary information to resolve it\nfunc (s *Store) GetTriggeredEndpointAlert(ep *endpoint.Endpoint, alert *alert.Alert) (exists bool, resolveKey string, numberOfSuccessesInARow int, err error) {\n\t//logr.Debugf(\"[sql.GetTriggeredEndpointAlert] Getting triggered alert with checksum=%s for endpoint with key=%s\", alert.Checksum(), ep.Key())\n\terr = s.db.QueryRow(\n\t\t\"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\",\n\t\tep.Key(),\n\t\talert.Checksum(),\n\t).Scan(&resolveKey, &numberOfSuccessesInARow)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn false, \"\", 0, nil\n\t\t}\n\t\treturn false, \"\", 0, err\n\t}\n\treturn true, resolveKey, numberOfSuccessesInARow, nil\n}\n\n// UpsertTriggeredEndpointAlert inserts/updates a triggered alert for an endpoint\n// Used for persistence of triggered alerts across application restarts\nfunc (s *Store) UpsertTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error {\n\t//logr.Debugf(\"[sql.UpsertTriggeredEndpointAlert] Upserting triggered alert with checksum=%s for endpoint with key=%s\", triggeredAlert.Checksum(), ep.Key())\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tendpointID, err := s.getEndpointID(tx, ep)\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\t// Endpoint doesn't exist in the database, insert it\n\t\t\t// This shouldn't happen, but we'll handle it anyway\n\t\t\tif endpointID, err = s.insertEndpoint(tx, ep); err != nil {\n\t\t\t\t_ = tx.Rollback()\n\t\t\t\tlogr.Errorf(\"[sql.UpsertTriggeredEndpointAlert] Failed to create endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else {\n\t\t\t_ = tx.Rollback()\n\t\t\tlogr.Errorf(\"[sql.UpsertTriggeredEndpointAlert] Failed to retrieve id of endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\treturn err\n\t\t}\n\t}\n\t_, err = tx.Exec(\n\t\t`\n\t\t\tINSERT INTO endpoint_alerts_triggered (endpoint_id, configuration_checksum, resolve_key, number_of_successes_in_a_row) \n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\tON CONFLICT(endpoint_id, configuration_checksum) DO UPDATE SET\n\t\t\t\tresolve_key = $3,\n\t\t\t\tnumber_of_successes_in_a_row = $4\n\t\t`,\n\t\tendpointID,\n\t\ttriggeredAlert.Checksum(),\n\t\ttriggeredAlert.ResolveKey,\n\t\tep.NumberOfSuccessesInARow, // We only persist NumberOfSuccessesInARow, because all alerts in this table are already triggered\n\t)\n\tif err != nil {\n\t\t_ = tx.Rollback()\n\t\tlogr.Errorf(\"[sql.UpsertTriggeredEndpointAlert] Failed to persist triggered alert for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\treturn err\n\t}\n\tif err = tx.Commit(); err != nil {\n\t\t_ = tx.Rollback()\n\t}\n\treturn nil\n}\n\n// DeleteTriggeredEndpointAlert deletes a triggered alert for an endpoint\nfunc (s *Store) DeleteTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error {\n\t//logr.Debugf(\"[sql.DeleteTriggeredEndpointAlert] Deleting triggered alert with checksum=%s for endpoint with key=%s\", triggeredAlert.Checksum(), ep.Key())\n\t_, 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())\n\treturn err\n}\n\n// DeleteAllTriggeredAlertsNotInChecksumsByEndpoint removes all triggered alerts owned by an endpoint whose alert\n// configurations are not provided in the checksums list.\n// This prevents triggered alerts that have been removed or modified from lingering in the database.\nfunc (s *Store) DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int {\n\t//logr.Debugf(\"[sql.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint] Deleting triggered alerts for endpoint with key=%s that do not belong to any of checksums=%v\", ep.Key(), checksums)\n\tvar err error\n\tvar result sql.Result\n\tif len(checksums) == 0 {\n\t\t// No checksums? Then it means there are no (enabled) alerts configured for that endpoint, so we can get rid of all\n\t\t// persisted triggered alerts for that endpoint\n\t\tresult, 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())\n\t} else {\n\t\targs := make([]interface{}, 0, len(checksums)+1)\n\t\targs = append(args, ep.Key())\n\t\tquery := `DELETE FROM endpoint_alerts_triggered \n\t\t\tWHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1)\n\t\t\t  AND configuration_checksum NOT IN (`\n\t\tfor i := range checksums {\n\t\t\tquery += fmt.Sprintf(\"$%d,\", i+2)\n\t\t\targs = append(args, checksums[i])\n\t\t}\n\t\tquery = query[:len(query)-1] + \")\" // Remove the last comma and add the closing parenthesis\n\t\tresult, err = s.db.Exec(query, args...)\n\t}\n\tif err != nil {\n\t\tlogr.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())\n\t\treturn 0\n\t}\n\t// Return number of rows deleted\n\trowsAffects, _ := result.RowsAffected()\n\treturn int(rowsAffects)\n}\n\n// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp\nfunc (s *Store) HasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error) {\n\tif timestamp.IsZero() {\n\t\treturn false, errors.New(\"timestamp is zero\")\n\t}\n\tvar count int\n\terr := s.db.QueryRow(\n\t\t\"SELECT COUNT(*) FROM endpoint_results WHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1) AND timestamp > $2\",\n\t\tkey,\n\t\ttimestamp.UTC(),\n\t).Scan(&count)\n\tif err != nil {\n\t\t// If the endpoint doesn't exist, we return false instead of an error\n\t\treturn false, nil\n\t}\n\treturn count > 0, nil\n}\n\n// Clear deletes everything from the store\nfunc (s *Store) Clear() {\n\t_, _ = s.db.Exec(\"DELETE FROM endpoints\")\n\tif s.writeThroughCache != nil {\n\t\t_ = s.writeThroughCache.DeleteKeysByPattern(\"*\")\n\t}\n}\n\n// Save does nothing, because this store is immediately persistent.\nfunc (s *Store) Save() error {\n\treturn nil\n}\n\n// Close the database handle\nfunc (s *Store) Close() {\n\t_ = s.db.Close()\n\tif s.writeThroughCache != nil {\n\t\t// Clear the cache too. If the store's been closed, we don't want to keep the cache around.\n\t\t_ = s.writeThroughCache.DeleteKeysByPattern(\"*\")\n\t}\n}\n\n// insertEndpoint inserts an endpoint in the store and returns the generated id of said endpoint\nfunc (s *Store) insertEndpoint(tx *sql.Tx, ep *endpoint.Endpoint) (int64, error) {\n\t//logr.Debugf(\"[sql.insertEndpoint] Inserting endpoint with group=%s and name=%s\", ep.Group, ep.Name)\n\tvar id int64\n\terr := tx.QueryRow(\n\t\t\"INSERT INTO endpoints (endpoint_key, endpoint_name, endpoint_group) VALUES ($1, $2, $3) RETURNING endpoint_id\",\n\t\tep.Key(),\n\t\tep.Name,\n\t\tep.Group,\n\t).Scan(&id)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn id, nil\n}\n\n// insertEndpointEvent inserts en event in the store\nfunc (s *Store) insertEndpointEvent(tx *sql.Tx, endpointID int64, event *endpoint.Event) error {\n\t_, err := tx.Exec(\n\t\t\"INSERT INTO endpoint_events (endpoint_id, event_type, event_timestamp) VALUES ($1, $2, $3)\",\n\t\tendpointID,\n\t\tevent.Type,\n\t\tevent.Timestamp.UTC(),\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// insertEndpointResult inserts a result in the store\nfunc (s *Store) insertEndpointResult(tx *sql.Tx, endpointID int64, result *endpoint.Result) error {\n\treturn s.insertEndpointResultWithSuiteID(tx, endpointID, result, nil)\n}\n\n// insertEndpointResultWithSuiteID inserts a result in the store with optional suite linkage\nfunc (s *Store) insertEndpointResultWithSuiteID(tx *sql.Tx, endpointID int64, result *endpoint.Result, suiteResultID *int64) error {\n\tvar endpointResultID int64\n\terr := tx.QueryRow(\n\t\t`\n\t\t\tINSERT INTO endpoint_results (endpoint_id, success, errors, connected, status, dns_rcode, certificate_expiration, domain_expiration, hostname, ip, duration, timestamp, suite_result_id)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)\n\t\t\tRETURNING endpoint_result_id\n\t\t`,\n\t\tendpointID,\n\t\tresult.Success,\n\t\tstrings.Join(result.Errors, arraySeparator),\n\t\tresult.Connected,\n\t\tresult.HTTPStatus,\n\t\tresult.DNSRCode,\n\t\tresult.CertificateExpiration,\n\t\tresult.DomainExpiration,\n\t\tresult.Hostname,\n\t\tresult.IP,\n\t\tresult.Duration,\n\t\tresult.Timestamp.UTC(),\n\t\tsuiteResultID,\n\t).Scan(&endpointResultID)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn s.insertConditionResults(tx, endpointResultID, result.ConditionResults)\n}\n\nfunc (s *Store) insertConditionResults(tx *sql.Tx, endpointResultID int64, conditionResults []*endpoint.ConditionResult) error {\n\tvar err error\n\tfor _, cr := range conditionResults {\n\t\t_, err = tx.Exec(\"INSERT INTO endpoint_result_conditions (endpoint_result_id, condition, success) VALUES ($1, $2, $3)\",\n\t\t\tendpointResultID,\n\t\t\tcr.Condition,\n\t\t\tcr.Success,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *Store) updateEndpointUptime(tx *sql.Tx, endpointID int64, result *endpoint.Result) error {\n\tunixTimestampFlooredAtHour := result.Timestamp.Truncate(time.Hour).Unix()\n\tvar successfulExecutions int\n\tif result.Success {\n\t\tsuccessfulExecutions = 1\n\t}\n\t_, err := tx.Exec(\n\t\t`\n\t\t\tINSERT INTO endpoint_uptimes (endpoint_id, hour_unix_timestamp, total_executions, successful_executions, total_response_time) \n\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\tON CONFLICT(endpoint_id, hour_unix_timestamp) DO UPDATE SET\n\t\t\t\ttotal_executions = excluded.total_executions + endpoint_uptimes.total_executions,\n\t\t\t\tsuccessful_executions = excluded.successful_executions + endpoint_uptimes.successful_executions,\n\t\t\t\ttotal_response_time = excluded.total_response_time + endpoint_uptimes.total_response_time\n\t\t`,\n\t\tendpointID,\n\t\tunixTimestampFlooredAtHour,\n\t\t1,\n\t\tsuccessfulExecutions,\n\t\tresult.Duration.Milliseconds(),\n\t)\n\treturn err\n}\n\nfunc (s *Store) getAllEndpointKeys(tx *sql.Tx) (keys []string, err error) {\n\t// Only get endpoints that have at least one result not linked to a suite\n\t// This excludes endpoints that only exist as part of suites\n\t// Using JOIN for better performance than EXISTS subquery\n\trows, err := tx.Query(`\n\t\tSELECT DISTINCT e.endpoint_key \n\t\tFROM endpoints e\n\t\tINNER JOIN endpoint_results er ON e.endpoint_id = er.endpoint_id\n\t\tWHERE er.suite_result_id IS NULL\n\t\tORDER BY e.endpoint_key\n\t`)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor rows.Next() {\n\t\tvar key string\n\t\t_ = rows.Scan(&key)\n\t\tkeys = append(keys, key)\n\t}\n\treturn\n}\n\nfunc (s *Store) getEndpointStatusByKey(tx *sql.Tx, key string, parameters *paging.EndpointStatusParams) (*endpoint.Status, error) {\n\tvar cacheKey string\n\tif s.writeThroughCache != nil {\n\t\tcacheKey = generateCacheKey(key, parameters)\n\t\tif cachedEndpointStatus, exists := s.writeThroughCache.Get(cacheKey); exists {\n\t\t\tif castedCachedEndpointStatus, ok := cachedEndpointStatus.(*endpoint.Status); ok {\n\t\t\t\treturn castedCachedEndpointStatus, nil\n\t\t\t}\n\t\t}\n\t}\n\tendpointID, group, endpointName, err := s.getEndpointIDGroupAndNameByKey(tx, key)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tendpointStatus := endpoint.NewStatus(group, endpointName)\n\tif parameters.EventsPageSize > 0 {\n\t\tif endpointStatus.Events, err = s.getEndpointEventsByEndpointID(tx, endpointID, parameters.EventsPage, parameters.EventsPageSize); err != nil {\n\t\t\tlogr.Errorf(\"[sql.getEndpointStatusByKey] Failed to retrieve events for key=%s: %s\", key, err.Error())\n\t\t}\n\t}\n\tif parameters.ResultsPageSize > 0 {\n\t\tif endpointStatus.Results, err = s.getEndpointResultsByEndpointID(tx, endpointID, parameters.ResultsPage, parameters.ResultsPageSize); err != nil {\n\t\t\tlogr.Errorf(\"[sql.getEndpointStatusByKey] Failed to retrieve results for key=%s: %s\", key, err.Error())\n\t\t}\n\t}\n\tif s.writeThroughCache != nil {\n\t\ts.writeThroughCache.SetWithTTL(cacheKey, endpointStatus, cacheTTL)\n\t}\n\treturn endpointStatus, nil\n}\n\nfunc (s *Store) getEndpointIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64, group, name string, err error) {\n\terr = tx.QueryRow(\n\t\t`\n\t\t\tSELECT endpoint_id, endpoint_group, endpoint_name\n\t\t\tFROM endpoints\n\t\t\tWHERE endpoint_key = $1\n\t\t\tLIMIT 1\n\t\t`,\n\t\tkey,\n\t).Scan(&id, &group, &name)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn 0, \"\", \"\", common.ErrEndpointNotFound\n\t\t}\n\t\treturn 0, \"\", \"\", err\n\t}\n\treturn\n}\n\nfunc (s *Store) getEndpointEventsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (events []*endpoint.Event, err error) {\n\t// We need to get the most recent events, but return them in chronological order (oldest to newest)\n\t// First, get the most recent events using a subquery, then order them chronologically\n\trows, err := tx.Query(\n\t\t`\n\t\t\tSELECT event_type, event_timestamp\n\t\t\tFROM (\n\t\t\t\tSELECT event_type, event_timestamp, endpoint_event_id\n\t\t\t\tFROM endpoint_events\n\t\t\t\tWHERE endpoint_id = $1\n\t\t\t\tORDER BY endpoint_event_id DESC\n\t\t\t\tLIMIT $2 OFFSET $3\n\t\t\t) AS recent_events\n\t\t\tORDER BY endpoint_event_id ASC\n\t\t`,\n\t\tendpointID,\n\t\tpageSize,\n\t\t(page-1)*pageSize,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor rows.Next() {\n\t\tevent := &endpoint.Event{}\n\t\t_ = rows.Scan(&event.Type, &event.Timestamp)\n\t\tevents = append(events, event)\n\t}\n\treturn\n}\n\nfunc (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (results []*endpoint.Result, err error) {\n\trows, err := tx.Query(\n\t\t`\n\t\t\tSELECT endpoint_result_id, success, errors, connected, status, dns_rcode, certificate_expiration, domain_expiration, hostname, ip, duration, timestamp\n\t\t\tFROM endpoint_results\n\t\t\tWHERE endpoint_id = $1\n\t\t\tORDER BY endpoint_result_id DESC -- Normally, we'd sort by timestamp, but sorting by endpoint_result_id is faster\n\t\t\tLIMIT $2 OFFSET $3\n\t\t`,\n\t\tendpointID,\n\t\tpageSize,\n\t\t(page-1)*pageSize,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tidResultMap := make(map[int64]*endpoint.Result)\n\tfor rows.Next() {\n\t\tresult := &endpoint.Result{}\n\t\tvar id int64\n\t\tvar joinedErrors string\n\t\terr = rows.Scan(&id, &result.Success, &joinedErrors, &result.Connected, &result.HTTPStatus, &result.DNSRCode, &result.CertificateExpiration, &result.DomainExpiration, &result.Hostname, &result.IP, &result.Duration, &result.Timestamp)\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[sql.getEndpointResultsByEndpointID] Silently failed to retrieve endpoint result for endpointID=%d: %s\", endpointID, err.Error())\n\t\t\terr = nil\n\t\t}\n\t\tif len(joinedErrors) != 0 {\n\t\t\tresult.Errors = strings.Split(joinedErrors, arraySeparator)\n\t\t}\n\t\t// This is faster than using a subselect\n\t\tresults = append([]*endpoint.Result{result}, results...)\n\t\tidResultMap[id] = result\n\t}\n\tif len(idResultMap) == 0 {\n\t\t// If there's no result, we'll just return an empty/nil slice\n\t\treturn\n\t}\n\t// Get condition results\n\targs := make([]interface{}, 0, len(idResultMap))\n\tquery := `SELECT endpoint_result_id, condition, success\n\t\t\t\tFROM endpoint_result_conditions\n\t\t\t\tWHERE endpoint_result_id IN (`\n\tindex := 1\n\tfor endpointResultID := range idResultMap {\n\t\tquery += \"$\" + strconv.Itoa(index) + \",\"\n\t\targs = append(args, endpointResultID)\n\t\tindex++\n\t}\n\tquery = query[:len(query)-1] + \")\"\n\trows, err = tx.Query(query, args...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close() // explicitly defer the close in case an error happens during the scan\n\tfor rows.Next() {\n\t\tconditionResult := &endpoint.ConditionResult{}\n\t\tvar endpointResultID int64\n\t\tif err = rows.Scan(&endpointResultID, &conditionResult.Condition, &conditionResult.Success); err != nil {\n\t\t\treturn\n\t\t}\n\t\tidResultMap[endpointResultID].ConditionResults = append(idResultMap[endpointResultID].ConditionResults, conditionResult)\n\t}\n\treturn\n}\n\nfunc (s *Store) getEndpointUptime(tx *sql.Tx, endpointID int64, from, to time.Time) (uptime float64, avgResponseTime time.Duration, err error) {\n\trows, err := tx.Query(\n\t\t`\n\t\t\tSELECT SUM(total_executions), SUM(successful_executions), SUM(total_response_time)\n\t\t\tFROM endpoint_uptimes\n\t\t\tWHERE endpoint_id = $1\n\t\t\t\tAND hour_unix_timestamp >= $2\n\t\t\t\tAND hour_unix_timestamp <= $3\n\t\t`,\n\t\tendpointID,\n\t\tfrom.Unix(),\n\t\tto.Unix(),\n\t)\n\tif err != nil {\n\t\treturn 0, 0, err\n\t}\n\tvar totalExecutions, totalSuccessfulExecutions, totalResponseTime int\n\tfor rows.Next() {\n\t\t_ = rows.Scan(&totalExecutions, &totalSuccessfulExecutions, &totalResponseTime)\n\t}\n\tif totalExecutions > 0 {\n\t\tuptime = float64(totalSuccessfulExecutions) / float64(totalExecutions)\n\t\tavgResponseTime = time.Duration(float64(totalResponseTime)/float64(totalExecutions)) * time.Millisecond\n\t}\n\treturn\n}\n\nfunc (s *Store) getEndpointAverageResponseTime(tx *sql.Tx, endpointID int64, from, to time.Time) (int, error) {\n\trows, err := tx.Query(\n\t\t`\n\t\t\tSELECT SUM(total_executions), SUM(total_response_time)\n\t\t\tFROM endpoint_uptimes\n\t\t\tWHERE endpoint_id = $1\n\t\t\t\tAND total_executions > 0\n\t\t\t\tAND hour_unix_timestamp >= $2\n\t\t\t\tAND hour_unix_timestamp <= $3\n\t\t`,\n\t\tendpointID,\n\t\tfrom.Unix(),\n\t\tto.Unix(),\n\t)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tvar totalExecutions, totalResponseTime int\n\tfor rows.Next() {\n\t\t_ = rows.Scan(&totalExecutions, &totalResponseTime)\n\t}\n\tif totalExecutions == 0 {\n\t\treturn 0, nil\n\t}\n\treturn int(float64(totalResponseTime) / float64(totalExecutions)), nil\n}\n\nfunc (s *Store) getEndpointHourlyAverageResponseTimes(tx *sql.Tx, endpointID int64, from, to time.Time) (map[int64]int, error) {\n\trows, err := tx.Query(\n\t\t`\n\t\t\tSELECT hour_unix_timestamp, total_executions, total_response_time\n\t\t\tFROM endpoint_uptimes\n\t\t\tWHERE endpoint_id = $1\n\t\t\t\tAND total_executions > 0\n\t\t\t\tAND hour_unix_timestamp >= $2\n\t\t\t\tAND hour_unix_timestamp <= $3\n\t\t`,\n\t\tendpointID,\n\t\tfrom.Unix(),\n\t\tto.Unix(),\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar totalExecutions, totalResponseTime int\n\tvar unixTimestampFlooredAtHour int64\n\thourlyAverageResponseTimes := make(map[int64]int)\n\tfor rows.Next() {\n\t\t_ = rows.Scan(&unixTimestampFlooredAtHour, &totalExecutions, &totalResponseTime)\n\t\thourlyAverageResponseTimes[unixTimestampFlooredAtHour] = int(float64(totalResponseTime) / float64(totalExecutions))\n\t}\n\treturn hourlyAverageResponseTimes, nil\n}\n\nfunc (s *Store) getEndpointID(tx *sql.Tx, ep *endpoint.Endpoint) (int64, error) {\n\tvar id int64\n\terr := tx.QueryRow(\"SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1\", ep.Key()).Scan(&id)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn 0, common.ErrEndpointNotFound\n\t\t}\n\t\treturn 0, err\n\t}\n\treturn id, nil\n}\n\nfunc (s *Store) getNumberOfEventsByEndpointID(tx *sql.Tx, endpointID int64) (int64, error) {\n\tvar numberOfEvents int64\n\terr := tx.QueryRow(\"SELECT COUNT(1) FROM endpoint_events WHERE endpoint_id = $1\", endpointID).Scan(&numberOfEvents)\n\treturn numberOfEvents, err\n}\n\nfunc (s *Store) getNumberOfResultsByEndpointID(tx *sql.Tx, endpointID int64) (int64, error) {\n\tvar numberOfResults int64\n\terr := tx.QueryRow(\"SELECT COUNT(1) FROM endpoint_results WHERE endpoint_id = $1\", endpointID).Scan(&numberOfResults)\n\treturn numberOfResults, err\n}\n\nfunc (s *Store) getNumberOfUptimeEntriesByEndpointID(tx *sql.Tx, endpointID int64) (int64, error) {\n\tvar numberOfUptimeEntries int64\n\terr := tx.QueryRow(\"SELECT COUNT(1) FROM endpoint_uptimes WHERE endpoint_id = $1\", endpointID).Scan(&numberOfUptimeEntries)\n\treturn numberOfUptimeEntries, err\n}\n\nfunc (s *Store) getAgeOfOldestEndpointUptimeEntry(tx *sql.Tx, endpointID int64) (time.Duration, error) {\n\trows, err := tx.Query(\n\t\t`\n\t\t\tSELECT hour_unix_timestamp \n\t\t\tFROM endpoint_uptimes \n\t\t\tWHERE endpoint_id = $1 \n\t\t\tORDER BY hour_unix_timestamp\n\t\t\tLIMIT 1\n\t\t`,\n\t\tendpointID,\n\t)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tvar oldestEndpointUptimeUnixTimestamp int64\n\tvar found bool\n\tfor rows.Next() {\n\t\t_ = rows.Scan(&oldestEndpointUptimeUnixTimestamp)\n\t\tfound = true\n\t}\n\tif !found {\n\t\treturn 0, errNoRowsReturned\n\t}\n\treturn time.Since(time.Unix(oldestEndpointUptimeUnixTimestamp, 0)), nil\n}\n\nfunc (s *Store) getLastEndpointResultSuccessValue(tx *sql.Tx, endpointID int64) (bool, error) {\n\tvar success bool\n\terr := tx.QueryRow(\"SELECT success FROM endpoint_results WHERE endpoint_id = $1 ORDER BY endpoint_result_id DESC LIMIT 1\", endpointID).Scan(&success)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn false, errNoRowsReturned\n\t\t}\n\t\treturn false, err\n\t}\n\treturn success, nil\n}\n\n// deleteOldEndpointEvents deletes endpoint events that are no longer needed\nfunc (s *Store) deleteOldEndpointEvents(tx *sql.Tx, endpointID int64) error {\n\t_, err := tx.Exec(\n\t\t`\n\t\t\tDELETE FROM endpoint_events \n\t\t\tWHERE endpoint_id = $1\n\t\t\t\tAND endpoint_event_id NOT IN (\n\t\t\t\t\tSELECT endpoint_event_id \n\t\t\t\t\tFROM endpoint_events\n\t\t\t\t\tWHERE endpoint_id = $1\n\t\t\t\t\tORDER BY endpoint_event_id DESC\n\t\t\t\t\tLIMIT $2\n\t\t\t\t)\n\t\t`,\n\t\tendpointID,\n\t\ts.maximumNumberOfEvents,\n\t)\n\treturn err\n}\n\n// deleteOldEndpointResults deletes endpoint results that are no longer needed\nfunc (s *Store) deleteOldEndpointResults(tx *sql.Tx, endpointID int64) error {\n\t_, err := tx.Exec(\n\t\t`\n\t\t\tDELETE FROM endpoint_results\n\t\t\tWHERE endpoint_id = $1 \n\t\t\t\tAND endpoint_result_id NOT IN (\n\t\t\t\t\tSELECT endpoint_result_id\n\t\t\t\t\tFROM endpoint_results\n\t\t\t\t\tWHERE endpoint_id = $1\n\t\t\t\t\tORDER BY endpoint_result_id DESC\n\t\t\t\t\tLIMIT $2\n\t\t\t\t)\n\t\t`,\n\t\tendpointID,\n\t\ts.maximumNumberOfResults,\n\t)\n\treturn err\n}\n\nfunc (s *Store) deleteOldUptimeEntries(tx *sql.Tx, endpointID int64, maxAge time.Time) error {\n\t_, err := tx.Exec(\"DELETE FROM endpoint_uptimes WHERE endpoint_id = $1 AND hour_unix_timestamp < $2\", endpointID, maxAge.Unix())\n\treturn err\n}\n\n// mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries merges all hourly uptime entries older than\n// uptimeHourlyMergeThreshold from now into daily uptime entries by summing all hourly entries of the same day into a\n// single entry.\n//\n// This effectively limits the number of uptime entries to (48+(n-2)) where 48 is for the first 48 entries with hourly\n// entries (defined by uptimeHourlyBuffer) and n is the number of days for all entries older than 48 hours.\n// Supporting 30d of entries would then result in far less than 24*30=720 entries.\nfunc (s *Store) mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries(tx *sql.Tx, endpointID int64) error {\n\t// Calculate timestamp of the first full day of uptime entries that would not impact the uptime calculation for 24h badges\n\t// The logic is that once at least 48 hours passed, we:\n\t// - No longer need to worry about keeping hourly entries\n\t// - Don't have to worry about new hourly entries being inserted, as the day has already passed\n\t// which implies that no matter at what hour of the day we are, any timestamp + 48h floored to the current day\n\t// will never impact the 24h uptime badge calculation\n\tnow := time.Now()\n\tminThreshold := now.Add(-uptimeHourlyBuffer)\n\tminThreshold = time.Date(minThreshold.Year(), minThreshold.Month(), minThreshold.Day(), 0, 0, 0, 0, minThreshold.Location())\n\tmaxThreshold := now.Add(-uptimeRetention)\n\t// Get all uptime entries older than uptimeHourlyMergeThreshold\n\trows, err := tx.Query(\n\t\t`\n\t\t\tSELECT hour_unix_timestamp, total_executions, successful_executions, total_response_time\n\t\t\tFROM endpoint_uptimes\n\t\t\tWHERE endpoint_id = $1\n\t\t\t\tAND hour_unix_timestamp < $2\n\t\t\t    AND hour_unix_timestamp >= $3\n\t\t`,\n\t\tendpointID,\n\t\tminThreshold.Unix(),\n\t\tmaxThreshold.Unix(),\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttype Entry struct {\n\t\ttotalExecutions      int\n\t\tsuccessfulExecutions int\n\t\ttotalResponseTime    int\n\t}\n\tdailyEntries := make(map[int64]*Entry)\n\tfor rows.Next() {\n\t\tvar unixTimestamp int64\n\t\tentry := Entry{}\n\t\tif err = rows.Scan(&unixTimestamp, &entry.totalExecutions, &entry.successfulExecutions, &entry.totalResponseTime); err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttimestamp := time.Unix(unixTimestamp, 0)\n\t\tunixTimestampFlooredAtDay := time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(), 0, 0, 0, 0, timestamp.Location()).Unix()\n\t\tif dailyEntry := dailyEntries[unixTimestampFlooredAtDay]; dailyEntry == nil {\n\t\t\tdailyEntries[unixTimestampFlooredAtDay] = &entry\n\t\t} else {\n\t\t\tdailyEntries[unixTimestampFlooredAtDay].totalExecutions += entry.totalExecutions\n\t\t\tdailyEntries[unixTimestampFlooredAtDay].successfulExecutions += entry.successfulExecutions\n\t\t\tdailyEntries[unixTimestampFlooredAtDay].totalResponseTime += entry.totalResponseTime\n\t\t}\n\t}\n\t// Delete older hourly uptime entries\n\t_, err = tx.Exec(\"DELETE FROM endpoint_uptimes WHERE endpoint_id = $1 AND hour_unix_timestamp < $2\", endpointID, minThreshold.Unix())\n\tif err != nil {\n\t\treturn err\n\t}\n\t// Insert new daily uptime entries\n\tfor unixTimestamp, entry := range dailyEntries {\n\t\t_, err = tx.Exec(\n\t\t\t`\n\t\t\t\t\tINSERT INTO endpoint_uptimes (endpoint_id, hour_unix_timestamp, total_executions, successful_executions, total_response_time)\n\t\t\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\t\t\tON CONFLICT(endpoint_id, hour_unix_timestamp) DO UPDATE SET\n\t\t\t\t\t\ttotal_executions = $3,\n\t\t\t\t\t\tsuccessful_executions = $4,\n\t\t\t\t\t\ttotal_response_time = $5\n\t\t\t\t`,\n\t\t\tendpointID,\n\t\t\tunixTimestamp,\n\t\t\tentry.totalExecutions,\n\t\t\tentry.successfulExecutions,\n\t\t\tentry.totalResponseTime,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t// TODO: Find a way to ignore entries that were already merged?\n\treturn nil\n}\n\nfunc generateCacheKey(endpointKey string, p *paging.EndpointStatusParams) string {\n\treturn fmt.Sprintf(\"%s-%d-%d-%d-%d\", endpointKey, p.EventsPage, p.EventsPageSize, p.ResultsPage, p.ResultsPageSize)\n}\n\nfunc extractKeyAndParamsFromCacheKey(cacheKey string) (string, *paging.EndpointStatusParams, error) {\n\tparts := strings.Split(cacheKey, \"-\")\n\tif len(parts) < 5 {\n\t\treturn \"\", nil, fmt.Errorf(\"invalid cache key: %s\", cacheKey)\n\t}\n\tparams := &paging.EndpointStatusParams{}\n\tvar err error\n\tif params.EventsPage, err = strconv.Atoi(parts[len(parts)-4]); err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"invalid cache key: %w\", err)\n\t}\n\tif params.EventsPageSize, err = strconv.Atoi(parts[len(parts)-3]); err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"invalid cache key: %w\", err)\n\t}\n\tif params.ResultsPage, err = strconv.Atoi(parts[len(parts)-2]); err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"invalid cache key: %w\", err)\n\t}\n\tif params.ResultsPageSize, err = strconv.Atoi(parts[len(parts)-1]); err != nil {\n\t\treturn \"\", nil, fmt.Errorf(\"invalid cache key: %w\", err)\n\t}\n\treturn strings.Join(parts[:len(parts)-4], \"-\"), params, nil\n}\n\n// GetAllSuiteStatuses returns all monitored suite statuses\nfunc (s *Store) GetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.Status, error) {\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer tx.Rollback()\n\n\t// Get all suites\n\trows, err := tx.Query(`\n\t\tSELECT suite_id, suite_key, suite_name, suite_group\n\t\tFROM suites\n\t\tORDER BY suite_key\n\t`)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar suiteStatuses []*suite.Status\n\tfor rows.Next() {\n\t\tvar suiteID int64\n\t\tvar key, name, group string\n\t\tif err = rows.Scan(&suiteID, &key, &name, &group); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tstatus := &suite.Status{\n\t\t\tName:    name,\n\t\t\tGroup:   group,\n\t\t\tKey:     key,\n\t\t\tResults: []*suite.Result{},\n\t\t}\n\n\t\t// Get suite results with pagination\n\t\tpageSize := 20\n\t\tpage := 1\n\t\tif params != nil {\n\t\t\tif params.PageSize > 0 {\n\t\t\t\tpageSize = params.PageSize\n\t\t\t}\n\t\t\tif params.Page > 0 {\n\t\t\t\tpage = params.Page\n\t\t\t}\n\t\t}\n\n\t\tstatus.Results, err = s.getSuiteResults(tx, suiteID, page, pageSize)\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[sql.GetAllSuiteStatuses] Failed to retrieve results for suite_id=%d: %s\", suiteID, err.Error())\n\t\t}\n\t\t// Populate Name and Group fields on each result\n\t\tfor _, result := range status.Results {\n\t\t\tresult.Name = name\n\t\t\tresult.Group = group\n\t\t}\n\n\t\tsuiteStatuses = append(suiteStatuses, status)\n\t}\n\n\tif err = tx.Commit(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn suiteStatuses, nil\n}\n\n// GetSuiteStatusByKey returns the suite status for a given key\nfunc (s *Store) GetSuiteStatusByKey(key string, params *paging.SuiteStatusParams) (*suite.Status, error) {\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer tx.Rollback()\n\n\tvar suiteID int64\n\tvar name, group string\n\terr = tx.QueryRow(`\n\t\tSELECT suite_id, suite_name, suite_group\n\t\tFROM suites\n\t\tWHERE suite_key = $1\n\t`, key).Scan(&suiteID, &name, &group)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tstatus := &suite.Status{\n\t\tName:    name,\n\t\tGroup:   group,\n\t\tKey:     key,\n\t\tResults: []*suite.Result{},\n\t}\n\n\t// Get suite results with pagination\n\tpageSize := 20\n\tpage := 1\n\tif params != nil {\n\t\tif params.PageSize > 0 {\n\t\t\tpageSize = params.PageSize\n\t\t}\n\t\tif params.Page > 0 {\n\t\t\tpage = params.Page\n\t\t}\n\t}\n\n\tstatus.Results, err = s.getSuiteResults(tx, suiteID, page, pageSize)\n\tif err != nil {\n\t\tlogr.Errorf(\"[sql.GetSuiteStatusByKey] Failed to retrieve results for suite_id=%d: %s\", suiteID, err.Error())\n\t}\n\t// Populate Name and Group fields on each result\n\tfor _, result := range status.Results {\n\t\tresult.Name = name\n\t\tresult.Group = group\n\t}\n\n\tif err = tx.Commit(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn status, nil\n}\n\n// InsertSuiteResult adds the observed result for the specified suite into the store\nfunc (s *Store) InsertSuiteResult(su *suite.Suite, result *suite.Result) error {\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\t// Get or create suite\n\tsuiteID, err := s.getSuiteID(tx, su)\n\tif err != nil {\n\t\tif errors.Is(err, common.ErrSuiteNotFound) {\n\t\t\t// Suite doesn't exist in the database, insert it\n\t\t\tif suiteID, err = s.insertSuite(tx, su); err != nil {\n\t\t\t\tlogr.Errorf(\"[sql.InsertSuiteResult] Failed to create suite with key=%s: %s\", su.Key(), err.Error())\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else {\n\t\t\tlogr.Errorf(\"[sql.InsertSuiteResult] Failed to retrieve id of suite with key=%s: %s\", su.Key(), err.Error())\n\t\t\treturn err\n\t\t}\n\t}\n\t// Insert suite result\n\tvar suiteResultID int64\n\terr = tx.QueryRow(`\n\t\tINSERT INTO suite_results (suite_id, success, errors, duration, timestamp)\n\t\tVALUES ($1, $2, $3, $4, $5)\n\t\tRETURNING suite_result_id\n\t`,\n\t\tsuiteID,\n\t\tresult.Success,\n\t\tstrings.Join(result.Errors, arraySeparator),\n\t\tresult.Duration.Nanoseconds(),\n\t\tresult.Timestamp.UTC(), // timestamp is the start time\n\t).Scan(&suiteResultID)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// For each endpoint result in the suite, we need to store them\n\tfor _, epResult := range result.EndpointResults {\n\t\t// Create a temporary endpoint object for storage\n\t\tep := &endpoint.Endpoint{\n\t\t\tName:  epResult.Name,\n\t\t\tGroup: su.Group,\n\t\t}\n\t\t// Get or create the endpoint (without suite linkage in endpoints table)\n\t\tepID, err := s.getEndpointID(tx, ep)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\t\t// Endpoint doesn't exist, create it\n\t\t\t\tif epID, err = s.insertEndpoint(tx, ep); err != nil {\n\t\t\t\t\tlogr.Errorf(\"[sql.InsertSuiteResult] Failed to create endpoint %s: %s\", epResult.Name, err.Error())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlogr.Errorf(\"[sql.InsertSuiteResult] Failed to get endpoint %s: %s\", epResult.Name, err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\t// InsertEndpointResult the endpoint result with suite linkage\n\t\terr = s.insertEndpointResultWithSuiteID(tx, epID, epResult, &suiteResultID)\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[sql.InsertSuiteResult] Failed to insert endpoint result for %s: %s\", epResult.Name, err.Error())\n\t\t}\n\t}\n\t// Clean up old suite results\n\tnumberOfResults, err := s.getNumberOfSuiteResultsByID(tx, suiteID)\n\tif err != nil {\n\t\tlogr.Errorf(\"[sql.InsertSuiteResult] Failed to retrieve total number of results for suite with key=%s: %s\", su.Key(), err.Error())\n\t} else {\n\t\tif numberOfResults > int64(s.maximumNumberOfResults+resultsAboveMaximumCleanUpThreshold) {\n\t\t\tif err = s.deleteOldSuiteResults(tx, suiteID); err != nil {\n\t\t\t\tlogr.Errorf(\"[sql.InsertSuiteResult] Failed to delete old results for suite with key=%s: %s\", su.Key(), err.Error())\n\t\t\t}\n\t\t}\n\t}\n\tif err = tx.Commit(); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// DeleteAllSuiteStatusesNotInKeys removes all suite statuses that are not within the keys provided\nfunc (s *Store) DeleteAllSuiteStatusesNotInKeys(keys []string) int {\n\tlogr.Debugf(\"[sql.DeleteAllSuiteStatusesNotInKeys] Called with %d keys\", len(keys))\n\tif len(keys) == 0 {\n\t\t// Delete all suites\n\t\tlogr.Debugf(\"[sql.DeleteAllSuiteStatusesNotInKeys] No keys provided, deleting all suites\")\n\t\tresult, err := s.db.Exec(\"DELETE FROM suites\")\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[sql.DeleteAllSuiteStatusesNotInKeys] Failed to delete all suites: %s\", err.Error())\n\t\t\treturn 0\n\t\t}\n\t\trowsAffected, _ := result.RowsAffected()\n\t\treturn int(rowsAffected)\n\t}\n\targs := make([]interface{}, 0, len(keys))\n\tquery := \"DELETE FROM suites WHERE suite_key NOT IN (\"\n\tfor i := range keys {\n\t\tif i > 0 {\n\t\t\tquery += \",\"\n\t\t}\n\t\tquery += fmt.Sprintf(\"$%d\", i+1)\n\t\targs = append(args, keys[i])\n\t}\n\tquery += \")\"\n\t// First, let's see what we're about to delete\n\tcheckQuery := \"SELECT suite_key FROM suites WHERE suite_key NOT IN (\"\n\tfor i := range keys {\n\t\tif i > 0 {\n\t\t\tcheckQuery += \",\"\n\t\t}\n\t\tcheckQuery += fmt.Sprintf(\"$%d\", i+1)\n\t}\n\tcheckQuery += \")\"\n\trows, err := s.db.Query(checkQuery, args...)\n\tif err == nil {\n\t\tdefer rows.Close()\n\t\tvar deletedKeys []string\n\t\tfor rows.Next() {\n\t\t\tvar key string\n\t\t\tif err := rows.Scan(&key); err == nil {\n\t\t\t\tdeletedKeys = append(deletedKeys, key)\n\t\t\t}\n\t\t}\n\t\tif len(deletedKeys) > 0 {\n\t\t\tlogr.Infof(\"[sql.DeleteAllSuiteStatusesNotInKeys] Deleting suites with keys: %v\", deletedKeys)\n\t\t}\n\t}\n\tresult, err := s.db.Exec(query, args...)\n\tif err != nil {\n\t\tlogr.Errorf(\"[sql.DeleteAllSuiteStatusesNotInKeys] Failed to delete suites: %s\", err.Error())\n\t\treturn 0\n\t}\n\trowsAffected, _ := result.RowsAffected()\n\treturn int(rowsAffected)\n}\n\n// Suite helper methods\n\n// getSuiteID retrieves the suite ID from the database by its key\nfunc (s *Store) getSuiteID(tx *sql.Tx, su *suite.Suite) (int64, error) {\n\tvar id int64\n\terr := tx.QueryRow(\"SELECT suite_id FROM suites WHERE suite_key = $1\", su.Key()).Scan(&id)\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn 0, common.ErrSuiteNotFound\n\t\t}\n\t\treturn 0, err\n\t}\n\treturn id, nil\n}\n\n// insertSuite inserts a suite in the store and returns the generated id\nfunc (s *Store) insertSuite(tx *sql.Tx, su *suite.Suite) (int64, error) {\n\tvar id int64\n\terr := tx.QueryRow(\n\t\t\"INSERT INTO suites (suite_key, suite_name, suite_group) VALUES ($1, $2, $3) RETURNING suite_id\",\n\t\tsu.Key(),\n\t\tsu.Name,\n\t\tsu.Group,\n\t).Scan(&id)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn id, nil\n}\n\n// getSuiteResults retrieves paginated suite results\nfunc (s *Store) getSuiteResults(tx *sql.Tx, suiteID int64, page, pageSize int) ([]*suite.Result, error) {\n\trows, err := tx.Query(`\n\t\tSELECT suite_result_id, success, errors, duration, timestamp\n\t\tFROM suite_results\n\t\tWHERE suite_id = $1\n\t\tORDER BY suite_result_id DESC\n\t\tLIMIT $2 OFFSET $3\n\t`,\n\t\tsuiteID,\n\t\tpageSize,\n\t\t(page-1)*pageSize,\n\t)\n\tif err != nil {\n\t\tlogr.Errorf(\"[sql.getSuiteResults] Query failed: %v\", err)\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\ttype suiteResultData struct {\n\t\tresult *suite.Result\n\t\tid     int64\n\t}\n\tvar resultsData []suiteResultData\n\tfor rows.Next() {\n\t\tresult := &suite.Result{\n\t\t\tEndpointResults: []*endpoint.Result{},\n\t\t}\n\t\tvar suiteResultID int64\n\t\tvar joinedErrors string\n\t\tvar nanoseconds int64\n\t\terr = rows.Scan(&suiteResultID, &result.Success, &joinedErrors, &nanoseconds, &result.Timestamp)\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[sql.getSuiteResults] Failed to scan suite result: %s\", err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tresult.Duration = time.Duration(nanoseconds)\n\t\tif len(joinedErrors) > 0 {\n\t\t\tresult.Errors = strings.Split(joinedErrors, arraySeparator)\n\t\t}\n\t\t// Store both result and ID together\n\t\tresultsData = append(resultsData, suiteResultData{\n\t\t\tresult: result,\n\t\t\tid:     suiteResultID,\n\t\t})\n\t}\n\n\t// Reverse the results to get chronological order (oldest to newest)\n\tfor i := len(resultsData)/2 - 1; i >= 0; i-- {\n\t\topp := len(resultsData) - 1 - i\n\t\tresultsData[i], resultsData[opp] = resultsData[opp], resultsData[i]\n\t}\n\t// Fetch endpoint results for each suite result\n\tfor _, data := range resultsData {\n\t\tresult := data.result\n\t\tresultID := data.id\n\t\t// Query endpoint results for this suite result\n\t\tepRows, err := tx.Query(`\n\t\t\tSELECT\n\t\t\t\ter.endpoint_result_id,\n\t\t\t\te.endpoint_name,\n\t\t\t\ter.success,\n\t\t\t\ter.errors,\n\t\t\t\ter.duration,\n\t\t\t\ter.timestamp\n\t\t\tFROM endpoint_results er\n\t\t\tJOIN endpoints e ON er.endpoint_id = e.endpoint_id\n\t\t\tWHERE er.suite_result_id = $1\n\t\t\tORDER BY er.endpoint_result_id\n\t\t`, resultID)\n\t\tif err != nil {\n\t\t\tlogr.Errorf(\"[sql.getSuiteResults] Failed to get endpoint results for suite_result_id=%d: %s\", resultID, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\t// Map to store endpoint results by their ID for condition lookup\n\t\tepResultMap := make(map[int64]*endpoint.Result)\n\t\tepCount := 0\n\t\tfor epRows.Next() {\n\t\t\tepCount++\n\t\t\tvar epResultID int64\n\t\t\tvar name string\n\t\t\tvar success bool\n\t\t\tvar joinedErrors string\n\t\t\tvar duration int64\n\t\t\tvar timestamp time.Time\n\t\t\terr = epRows.Scan(&epResultID, &name, &success, &joinedErrors, &duration, &timestamp)\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[sql.getSuiteResults] Failed to scan endpoint result: %s\", err.Error())\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tepResult := &endpoint.Result{\n\t\t\t\tName:             name,\n\t\t\t\tSuccess:          success,\n\t\t\t\tDuration:         time.Duration(duration),\n\t\t\t\tTimestamp:        timestamp,\n\t\t\t\tConditionResults: []*endpoint.ConditionResult{}, // Initialize empty slice\n\t\t\t}\n\t\t\tif len(joinedErrors) > 0 {\n\t\t\t\tepResult.Errors = strings.Split(joinedErrors, arraySeparator)\n\t\t\t}\n\t\t\tepResultMap[epResultID] = epResult\n\t\t\tresult.EndpointResults = append(result.EndpointResults, epResult)\n\t\t}\n\t\tepRows.Close()\n\t\t// Fetch condition results for all endpoint results in this suite result\n\t\tif len(epResultMap) > 0 {\n\t\t\targs := make([]interface{}, 0, len(epResultMap))\n\t\t\tcondQuery := `SELECT endpoint_result_id, condition, success\n\t\t\t\t\t\t  FROM endpoint_result_conditions\n\t\t\t\t\t\t  WHERE endpoint_result_id IN (`\n\t\t\tindex := 1\n\t\t\tfor epResultID := range epResultMap {\n\t\t\t\tcondQuery += \"$\" + strconv.Itoa(index) + \",\"\n\t\t\t\targs = append(args, epResultID)\n\t\t\t\tindex++\n\t\t\t}\n\t\t\tcondQuery = condQuery[:len(condQuery)-1] + \")\"\n\n\t\t\tcondRows, err := tx.Query(condQuery, args...)\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[sql.getSuiteResults] Failed to get condition results for suite_result_id=%d: %s\", resultID, err.Error())\n\t\t\t} else {\n\t\t\t\tcondCount := 0\n\t\t\t\tfor condRows.Next() {\n\t\t\t\t\tcondCount++\n\t\t\t\t\tconditionResult := &endpoint.ConditionResult{}\n\t\t\t\t\tvar epResultID int64\n\t\t\t\t\tif err = condRows.Scan(&epResultID, &conditionResult.Condition, &conditionResult.Success); err != nil {\n\t\t\t\t\t\tlogr.Errorf(\"[sql.getSuiteResults] Failed to scan condition result: %s\", err.Error())\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif epResult, exists := epResultMap[epResultID]; exists {\n\t\t\t\t\t\tepResult.ConditionResults = append(epResult.ConditionResults, conditionResult)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcondRows.Close()\n\t\t\t\tif condCount > 0 {\n\t\t\t\t\tlogr.Debugf(\"[sql.getSuiteResults] Found %d condition results for suite_result_id=%d\", condCount, resultID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif epCount > 0 {\n\t\t\tlogr.Debugf(\"[sql.getSuiteResults] Found %d endpoint results for suite_result_id=%d\", epCount, resultID)\n\t\t}\n\t}\n\t// Extract just the results for return\n\tvar results []*suite.Result\n\tfor _, data := range resultsData {\n\t\tresults = append(results, data.result)\n\t}\n\treturn results, nil\n}\n\n// getNumberOfSuiteResultsByID gets the count of results for a suite\nfunc (s *Store) getNumberOfSuiteResultsByID(tx *sql.Tx, suiteID int64) (int64, error) {\n\tvar count int64\n\terr := tx.QueryRow(\"SELECT COUNT(1) FROM suite_results WHERE suite_id = $1\", suiteID).Scan(&count)\n\treturn count, err\n}\n\n// deleteOldSuiteResults deletes old suite results beyond the maximum\nfunc (s *Store) deleteOldSuiteResults(tx *sql.Tx, suiteID int64) error {\n\t_, err := tx.Exec(`\n\t\tDELETE FROM suite_results\n\t\tWHERE suite_id = $1 \n\t\t\tAND suite_result_id NOT IN (\n\t\t\t\tSELECT suite_result_id\n\t\t\t\tFROM suite_results\n\t\t\t\tWHERE suite_id = $1\n\t\t\t\tORDER BY suite_result_id DESC\n\t\t\t\tLIMIT $2\n\t\t\t)\n\t`,\n\t\tsuiteID,\n\t\ts.maximumNumberOfResults,\n\t)\n\treturn err\n}\n"
  },
  {
    "path": "storage/store/sql/sql_test.go",
    "content": "package sql\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n)\n\nvar (\n\tfirstCondition  = endpoint.Condition(\"[STATUS] == 200\")\n\tsecondCondition = endpoint.Condition(\"[RESPONSE_TIME] < 500\")\n\tthirdCondition  = endpoint.Condition(\"[CERTIFICATE_EXPIRATION] < 72h\")\n\n\tnow = time.Now()\n\n\ttestEndpoint = endpoint.Endpoint{\n\t\tName:                    \"name\",\n\t\tGroup:                   \"group\",\n\t\tURL:                     \"https://example.org/what/ever\",\n\t\tMethod:                  \"GET\",\n\t\tBody:                    \"body\",\n\t\tInterval:                30 * time.Second,\n\t\tConditions:              []endpoint.Condition{firstCondition, secondCondition, thirdCondition},\n\t\tAlerts:                  nil,\n\t\tNumberOfFailuresInARow:  0,\n\t\tNumberOfSuccessesInARow: 0,\n\t}\n\ttestSuccessfulResult = endpoint.Result{\n\t\tHostname:              \"example.org\",\n\t\tIP:                    \"127.0.0.1\",\n\t\tHTTPStatus:            200,\n\t\tErrors:                nil,\n\t\tConnected:             true,\n\t\tSuccess:               true,\n\t\tTimestamp:             now,\n\t\tDuration:              150 * time.Millisecond,\n\t\tCertificateExpiration: 10 * time.Hour,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{\n\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[CERTIFICATE_EXPIRATION] < 72h\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t},\n\t}\n\ttestUnsuccessfulResult = endpoint.Result{\n\t\tHostname:              \"example.org\",\n\t\tIP:                    \"127.0.0.1\",\n\t\tHTTPStatus:            200,\n\t\tErrors:                []string{\"error-1\", \"error-2\"},\n\t\tConnected:             true,\n\t\tSuccess:               false,\n\t\tTimestamp:             now,\n\t\tDuration:              750 * time.Millisecond,\n\t\tCertificateExpiration: 10 * time.Hour,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{\n\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\tSuccess:   false,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[CERTIFICATE_EXPIRATION] < 72h\",\n\t\t\t\tSuccess:   false,\n\t\t\t},\n\t\t},\n\t}\n)\n\nfunc TestNewStore(t *testing.T) {\n\tif _, err := NewStore(\"\", t.TempDir()+\"/TestNewStore.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents); !errors.Is(err, ErrDatabaseDriverNotSpecified) {\n\t\tt.Error(\"expected error due to blank driver parameter\")\n\t}\n\tif _, err := NewStore(\"sqlite\", \"\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents); !errors.Is(err, ErrPathNotSpecified) {\n\t\tt.Error(\"expected error due to blank path parameter\")\n\t}\n\tif store, err := NewStore(\"sqlite\", t.TempDir()+\"/TestNewStore.db\", true, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents); err != nil {\n\t\tt.Error(\"shouldn't have returned any error, got\", err.Error())\n\t} else {\n\t\t_ = store.db.Close()\n\t}\n}\n\nfunc TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_InsertCleansUpOldUptimeEntriesProperly.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\tnow := time.Now().Truncate(time.Hour)\n\tnow = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())\n\n\tstore.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-5 * time.Hour), Success: true})\n\n\ttx, _ := store.db.Begin()\n\toldest, _ := store.getAgeOfOldestEndpointUptimeEntry(tx, 1)\n\t_ = tx.Commit()\n\tif oldest.Truncate(time.Hour) != 5*time.Hour {\n\t\tt.Errorf(\"oldest endpoint uptime entry should've been ~5 hours old, was %s\", oldest)\n\t}\n\n\t// The oldest cache entry should remain at ~5 hours old, because this entry is more recent\n\tstore.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-3 * time.Hour), Success: true})\n\n\ttx, _ = store.db.Begin()\n\toldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)\n\t_ = tx.Commit()\n\tif oldest.Truncate(time.Hour) != 5*time.Hour {\n\t\tt.Errorf(\"oldest endpoint uptime entry should've been ~5 hours old, was %s\", oldest)\n\t}\n\n\t// The oldest cache entry should now become at ~8 hours old, because this entry is older\n\tstore.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-8 * time.Hour), Success: true})\n\n\ttx, _ = store.db.Begin()\n\toldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)\n\t_ = tx.Commit()\n\tif oldest.Truncate(time.Hour) != 8*time.Hour {\n\t\tt.Errorf(\"oldest endpoint uptime entry should've been ~8 hours old, was %s\", oldest)\n\t}\n\n\t// Since this is one hour before reaching the clean up threshold, the oldest entry should now be this one\n\tstore.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeAgeCleanUpThreshold - time.Hour)), Success: true})\n\n\ttx, _ = store.db.Begin()\n\toldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)\n\t_ = tx.Commit()\n\tif oldest.Truncate(time.Hour) != uptimeAgeCleanUpThreshold-time.Hour {\n\t\tt.Errorf(\"oldest endpoint uptime entry should've been ~%s hours old, was %s\", uptimeAgeCleanUpThreshold-time.Hour, oldest)\n\t}\n\n\t// Since this entry is after the uptimeAgeCleanUpThreshold, both this entry as well as the previous\n\t// one should be deleted since they both surpass uptimeRetention\n\tstore.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeAgeCleanUpThreshold + time.Hour)), Success: true})\n\n\ttx, _ = store.db.Begin()\n\toldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)\n\t_ = tx.Commit()\n\tif oldest.Truncate(time.Hour) != 8*time.Hour {\n\t\tt.Errorf(\"oldest endpoint uptime entry should've been ~8 hours old, was %s\", oldest)\n\t}\n}\n\nfunc TestStore_HourlyUptimeEntriesAreMergedIntoDailyUptimeEntriesProperly(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_HourlyUptimeEntriesAreMergedIntoDailyUptimeEntriesProperly.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\tnow := time.Now().Truncate(time.Hour)\n\tnow = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())\n\n\tscenarios := []struct {\n\t\tnumberOfHours            int\n\t\texpectedMaxUptimeEntries int64\n\t}{\n\t\t{numberOfHours: 1, expectedMaxUptimeEntries: 1},\n\t\t{numberOfHours: 10, expectedMaxUptimeEntries: 10},\n\t\t{numberOfHours: 50, expectedMaxUptimeEntries: 50},\n\t\t{numberOfHours: 75, expectedMaxUptimeEntries: 75},\n\t\t{numberOfHours: 99, expectedMaxUptimeEntries: 99},\n\t\t{numberOfHours: 150, expectedMaxUptimeEntries: 100},\n\t\t{numberOfHours: 300, expectedMaxUptimeEntries: 100},\n\t\t{numberOfHours: 768, expectedMaxUptimeEntries: 100}, // 32 days (in hours), which means anything beyond that won't be persisted anyway\n\t\t{numberOfHours: 1000, expectedMaxUptimeEntries: 100},\n\t}\n\t// Note that is not technically an accurate real world representation, because uptime entries are always added in\n\t// the present, while this test is inserting results from the past to simulate long term uptime entries.\n\t// Since we want to test the behavior and not the test itself, this is a \"best effort\" approach.\n\tfor _, scenario := range scenarios {\n\t\tt.Run(fmt.Sprintf(\"num-hours-%d-expected-max-entries-%d\", scenario.numberOfHours, scenario.expectedMaxUptimeEntries), func(t *testing.T) {\n\t\t\tfor i := scenario.numberOfHours; i > 0; i-- {\n\t\t\t\t//fmt.Printf(\"i: %d (%s)\\n\", i, now.Add(-time.Duration(i)*time.Hour))\n\t\t\t\t// Create an uptime entry\n\t\t\t\terr := store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-time.Duration(i) * time.Hour), Success: true})\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Log(err)\n\t\t\t\t}\n\t\t\t\t//// DEBUGGING: check number of uptime entries for endpoint\n\t\t\t\t//tx, _ := store.db.Begin()\n\t\t\t\t//numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1)\n\t\t\t\t//if err != nil {\n\t\t\t\t//\tt.Log(err)\n\t\t\t\t//}\n\t\t\t\t//_ = tx.Commit()\n\t\t\t\t//t.Logf(\"i=%d; numberOfHours=%d; There are currently %d uptime entries for endpointID=%d\", i, scenario.numberOfHours, numberOfUptimeEntriesForEndpoint, 1)\n\t\t\t}\n\t\t\t// check number of uptime entries for endpoint\n\t\t\ttx, _ := store.db.Begin()\n\t\t\tnumberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1)\n\t\t\tif err != nil {\n\t\t\t\tt.Log(err)\n\t\t\t}\n\t\t\t_ = tx.Commit()\n\t\t\t//t.Logf(\"numberOfHours=%d; There are currently %d uptime entries for endpointID=%d\", scenario.numberOfHours, numberOfUptimeEntriesForEndpoint, 1)\n\t\t\tif scenario.expectedMaxUptimeEntries < numberOfUptimeEntriesForEndpoint {\n\t\t\t\tt.Errorf(\"expected %d (uptime entries) to be smaller than %d\", numberOfUptimeEntriesForEndpoint, scenario.expectedMaxUptimeEntries)\n\t\t\t}\n\t\t\tstore.Clear()\n\t\t})\n\t}\n}\n\nfunc TestStore_getEndpointUptime(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_InsertCleansUpEventsAndResultsProperly.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Clear()\n\tdefer store.Close()\n\t// Add 768 hourly entries (32 days)\n\t// Daily entries should be merged from hourly entries automatically\n\tfor i := 768; i > 0; i-- {\n\t\terr := store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: time.Now().Add(-time.Duration(i) * time.Hour), Duration: time.Second, Success: true})\n\t\tif err != nil {\n\t\t\tt.Log(err)\n\t\t}\n\t}\n\t// Check the number of uptime entries\n\ttx, _ := store.db.Begin()\n\tnumberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1)\n\tif err != nil {\n\t\tt.Log(err)\n\t}\n\tif numberOfUptimeEntriesForEndpoint < 20 || numberOfUptimeEntriesForEndpoint > 200 {\n\t\tt.Errorf(\"expected number of uptime entries to be between 20 and 200, got %d\", numberOfUptimeEntriesForEndpoint)\n\t}\n\t// Retrieve uptime for the past 30d\n\tuptime, avgResponseTime, err := store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now())\n\tif err != nil {\n\t\tt.Log(err)\n\t}\n\t_ = tx.Commit()\n\tif avgResponseTime != time.Second {\n\t\tt.Errorf(\"expected average response time to be %s, got %s\", time.Second, avgResponseTime)\n\t}\n\tif uptime != 1 {\n\t\tt.Errorf(\"expected uptime to be 1, got %f\", uptime)\n\t}\n\t// Add a new unsuccessful result, which should impact the uptime\n\terr = store.InsertEndpointResult(&testEndpoint, &endpoint.Result{Timestamp: time.Now(), Duration: time.Second, Success: false})\n\tif err != nil {\n\t\tt.Log(err)\n\t}\n\t// Retrieve uptime for the past 30d\n\ttx, _ = store.db.Begin()\n\tuptime, _, err = store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now())\n\tif err != nil {\n\t\tt.Log(err)\n\t}\n\t_ = tx.Commit()\n\tif uptime == 1 {\n\t\tt.Errorf(\"expected uptime to be less than 1, got %f\", uptime)\n\t}\n\t// Retrieve uptime for the past 30d, but excluding the last 24h\n\t// 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\n\t// to ensure that hourly merging works as intended\n\ttx, _ = store.db.Begin()\n\tuptimeExcludingLast24h, _, err := store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now().Add(-24*time.Hour))\n\tif err != nil {\n\t\tt.Log(err)\n\t}\n\t_ = tx.Commit()\n\tif uptimeExcludingLast24h == uptime {\n\t\tt.Error(\"expected uptimeExcludingLast24h to to be different from uptime, got\")\n\t}\n}\n\nfunc TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_InsertCleansUpEventsAndResultsProperly.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Clear()\n\tdefer store.Close()\n\tresultsCleanUpThreshold := store.maximumNumberOfResults + resultsAboveMaximumCleanUpThreshold\n\teventsCleanUpThreshold := store.maximumNumberOfEvents + eventsAboveMaximumCleanUpThreshold\n\tfor i := 0; i < resultsCleanUpThreshold+eventsCleanUpThreshold; i++ {\n\t\tstore.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\t\tstore.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)\n\t\tss, _ := store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, storage.DefaultMaximumNumberOfResults*5).WithEvents(1, storage.DefaultMaximumNumberOfEvents*5))\n\t\tif len(ss.Results) > resultsCleanUpThreshold+1 {\n\t\t\tt.Errorf(\"number of results shouldn't have exceeded %d, reached %d\", resultsCleanUpThreshold, len(ss.Results))\n\t\t}\n\t\tif len(ss.Events) > eventsCleanUpThreshold+1 {\n\t\t\tt.Errorf(\"number of events shouldn't have exceeded %d, reached %d\", eventsCleanUpThreshold, len(ss.Events))\n\t\t}\n\t}\n}\n\nfunc TestStore_InsertWithCaching(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_InsertWithCaching.db\", true, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\t// Add 2 results\n\tstore.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\tstore.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\t// Verify that they exist\n\tendpointStatuses, _ := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))\n\tif numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {\n\t\tt.Fatalf(\"expected 1 EndpointStatus, got %d\", numberOfEndpointStatuses)\n\t}\n\tif len(endpointStatuses[0].Results) != 2 {\n\t\tt.Fatalf(\"expected 2 results, got %d\", len(endpointStatuses[0].Results))\n\t}\n\t// Add 2 more results\n\tstore.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)\n\tstore.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)\n\t// Verify that they exist\n\tendpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))\n\tif numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {\n\t\tt.Fatalf(\"expected 1 EndpointStatus, got %d\", numberOfEndpointStatuses)\n\t}\n\tif len(endpointStatuses[0].Results) != 4 {\n\t\tt.Fatalf(\"expected 4 results, got %d\", len(endpointStatuses[0].Results))\n\t}\n\t// Clear the store, which should also clear the cache\n\tstore.Clear()\n\t// Verify that they no longer exist\n\tendpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))\n\tif numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 0 {\n\t\tt.Fatalf(\"expected 0 EndpointStatus, got %d\", numberOfEndpointStatuses)\n\t}\n}\n\nfunc TestStore_Persistence(t *testing.T) {\n\tpath := t.TempDir() + \"/TestStore_Persistence.db\"\n\tstore, _ := NewStore(\"sqlite\", path, false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tstore.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\tstore.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)\n\tif uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); uptime != 0.5 {\n\t\tt.Errorf(\"the uptime over the past 1h should've been 0.5, got %f\", uptime)\n\t}\n\tif uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24), time.Now()); uptime != 0.5 {\n\t\tt.Errorf(\"the uptime over the past 24h should've been 0.5, got %f\", uptime)\n\t}\n\tif uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {\n\t\tt.Errorf(\"the uptime over the past 7d should've been 0.5, got %f\", uptime)\n\t}\n\tif uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24*30), time.Now()); uptime != 0.5 {\n\t\tt.Errorf(\"the uptime over the past 30d should've been 0.5, got %f\", uptime)\n\t}\n\tssFromOldStore, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, storage.DefaultMaximumNumberOfResults).WithEvents(1, storage.DefaultMaximumNumberOfEvents))\n\tif ssFromOldStore == nil || ssFromOldStore.Group != \"group\" || ssFromOldStore.Name != \"name\" || len(ssFromOldStore.Events) != 3 || len(ssFromOldStore.Results) != 2 {\n\t\tstore.Close()\n\t\tt.Fatal(\"sanity check failed\")\n\t}\n\tstore.Close()\n\tstore, _ = NewStore(\"sqlite\", path, false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\tssFromNewStore, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, storage.DefaultMaximumNumberOfResults).WithEvents(1, storage.DefaultMaximumNumberOfEvents))\n\tif ssFromNewStore == nil || ssFromNewStore.Group != \"group\" || ssFromNewStore.Name != \"name\" || len(ssFromNewStore.Events) != 3 || len(ssFromNewStore.Results) != 2 {\n\t\tt.Fatal(\"failed sanity check\")\n\t}\n\tif ssFromNewStore == ssFromOldStore {\n\t\tt.Fatal(\"ss from the old and new store should have a different memory address\")\n\t}\n\tfor i := range ssFromNewStore.Events {\n\t\tif ssFromNewStore.Events[i].Timestamp != ssFromOldStore.Events[i].Timestamp {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t}\n\t\tif ssFromNewStore.Events[i].Type != ssFromOldStore.Events[i].Type {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t}\n\t}\n\tfor i := range ssFromOldStore.Results {\n\t\tif ssFromNewStore.Results[i].Timestamp != ssFromOldStore.Results[i].Timestamp {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t}\n\t\tif ssFromNewStore.Results[i].Success != ssFromOldStore.Results[i].Success {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t}\n\t\tif ssFromNewStore.Results[i].Connected != ssFromOldStore.Results[i].Connected {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t}\n\t\tif ssFromNewStore.Results[i].IP != ssFromOldStore.Results[i].IP {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t}\n\t\tif ssFromNewStore.Results[i].Hostname != ssFromOldStore.Results[i].Hostname {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t}\n\t\tif ssFromNewStore.Results[i].HTTPStatus != ssFromOldStore.Results[i].HTTPStatus {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t}\n\t\tif ssFromNewStore.Results[i].DNSRCode != ssFromOldStore.Results[i].DNSRCode {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t}\n\t\tif len(ssFromNewStore.Results[i].Errors) != len(ssFromOldStore.Results[i].Errors) {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t} else {\n\t\t\tfor j := range ssFromOldStore.Results[i].Errors {\n\t\t\t\tif ssFromNewStore.Results[i].Errors[j] != ssFromOldStore.Results[i].Errors[j] {\n\t\t\t\t\tt.Error(\"new and old should've been the same\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif len(ssFromNewStore.Results[i].ConditionResults) != len(ssFromOldStore.Results[i].ConditionResults) {\n\t\t\tt.Error(\"new and old should've been the same\")\n\t\t} else {\n\t\t\tfor j := range ssFromOldStore.Results[i].ConditionResults {\n\t\t\t\tif ssFromNewStore.Results[i].ConditionResults[j].Condition != ssFromOldStore.Results[i].ConditionResults[j].Condition {\n\t\t\t\t\tt.Error(\"new and old should've been the same\")\n\t\t\t\t}\n\t\t\t\tif ssFromNewStore.Results[i].ConditionResults[j].Success != ssFromOldStore.Results[i].ConditionResults[j].Success {\n\t\t\t\t\tt.Error(\"new and old should've been the same\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestStore_Save(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_Save.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\tif store.Save() != nil {\n\t\tt.Error(\"Save shouldn't do anything for this store\")\n\t}\n}\n\n// Note that are much more extensive tests in /storage/store/store_test.go.\n// This test is simply an extra sanity check\nfunc TestStore_SanityCheck(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_SanityCheck.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\tstore.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\tendpointStatuses, _ := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())\n\tif numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {\n\t\tt.Fatalf(\"expected 1 EndpointStatus, got %d\", numberOfEndpointStatuses)\n\t}\n\tstore.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)\n\t// Both results inserted are for the same endpoint, therefore, the count shouldn't have increased\n\tendpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())\n\tif numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 {\n\t\tt.Fatalf(\"expected 1 EndpointStatus, got %d\", numberOfEndpointStatuses)\n\t}\n\tif hourlyAverageResponseTime, err := store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); err != nil {\n\t\tt.Errorf(\"expected no error, got %v\", err)\n\t} else if len(hourlyAverageResponseTime) != 1 {\n\t\tt.Errorf(\"expected 1 hour to have had a result in the past 24 hours, got %d\", len(hourlyAverageResponseTime))\n\t}\n\tif uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); uptime != 0.5 {\n\t\tt.Errorf(\"expected uptime of last 24h to be 0.5, got %f\", uptime)\n\t}\n\tif averageResponseTime, _ := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-24*time.Hour), time.Now()); averageResponseTime != 450 {\n\t\tt.Errorf(\"expected average response time of last 24h to be 450, got %d\", averageResponseTime)\n\t}\n\tss, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, 20).WithEvents(1, 20))\n\tif ss == nil {\n\t\tt.Fatalf(\"Store should've had key '%s', but didn't\", testEndpoint.Key())\n\t}\n\tif len(ss.Events) != 3 {\n\t\tt.Errorf(\"Endpoint '%s' should've had 3 events, got %d\", ss.Name, len(ss.Events))\n\t}\n\tif len(ss.Results) != 2 {\n\t\tt.Errorf(\"Endpoint '%s' should've had 2 results, got %d\", ss.Name, len(ss.Results))\n\t}\n\tif deleted := store.DeleteAllEndpointStatusesNotInKeys([]string{\"invalid-key-which-means-everything-should-get-deleted\"}); deleted != 1 {\n\t\tt.Errorf(\"%d entries should've been deleted, got %d\", 1, deleted)\n\t}\n\tif deleted := store.DeleteAllEndpointStatusesNotInKeys([]string{}); deleted != 0 {\n\t\tt.Errorf(\"There should've been no entries left to delete, got %d\", deleted)\n\t}\n}\n\n// TestStore_InvalidTransaction tests what happens if an invalid transaction is passed as parameter\nfunc TestStore_InvalidTransaction(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_InvalidTransaction.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\ttx, _ := store.db.Begin()\n\ttx.Commit()\n\tif _, err := store.insertEndpoint(tx, &testEndpoint); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif err := store.insertEndpointEvent(tx, 1, endpoint.NewEventFromResult(&testSuccessfulResult)); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif err := store.insertEndpointResult(tx, 1, &testSuccessfulResult); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif err := store.insertConditionResults(tx, 1, testSuccessfulResult.ConditionResults); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif err := store.updateEndpointUptime(tx, 1, &testSuccessfulResult); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, err := store.getAllEndpointKeys(tx); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, err := store.getEndpointStatusByKey(tx, testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, 20)); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, err := store.getEndpointEventsByEndpointID(tx, 1, 1, 50); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, err := store.getEndpointResultsByEndpointID(tx, 1, 1, 50); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif err := store.deleteOldEndpointEvents(tx, 1); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif err := store.deleteOldEndpointResults(tx, 1); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, _, err := store.getEndpointUptime(tx, 1, time.Now(), time.Now()); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, err := store.getEndpointID(tx, &testEndpoint); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, err := store.getNumberOfEventsByEndpointID(tx, 1); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, err := store.getNumberOfResultsByEndpointID(tx, 1); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, err := store.getAgeOfOldestEndpointUptimeEntry(tx, 1); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n\tif _, err := store.getLastEndpointResultSuccessValue(tx, 1); err == nil {\n\t\tt.Error(\"should've returned an error, because the transaction was already committed\")\n\t}\n}\n\nfunc TestStore_NoRows(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_NoRows.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\ttx, _ := store.db.Begin()\n\tdefer tx.Rollback()\n\tif _, err := store.getLastEndpointResultSuccessValue(tx, 1); !errors.Is(err, errNoRowsReturned) {\n\t\tt.Errorf(\"should've %v, got %v\", errNoRowsReturned, err)\n\t}\n\tif _, err := store.getAgeOfOldestEndpointUptimeEntry(tx, 1); !errors.Is(err, errNoRowsReturned) {\n\t\tt.Errorf(\"should've %v, got %v\", errNoRowsReturned, err)\n\t}\n}\n\n// This tests very unlikely cases where a table is deleted.\nfunc TestStore_BrokenSchema(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_BrokenSchema.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tif _, err := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tif _, err := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams()); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\t// Break\n\t_, _ = store.db.Exec(\"DROP TABLE endpoints\")\n\t// And now we'll try to insert something in our broken schema\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\tif _, err := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\tif _, err := store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\tif _, err := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams()); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\tif _, err := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\tif _, err := store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams()); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\t// Repair\n\tif err := store.createSchema(); err != nil {\n\t\tt.Fatal(\"schema should've been repaired\")\n\t}\n\tstore.Clear()\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\t// Break\n\t_, _ = store.db.Exec(\"DROP TABLE endpoint_events\")\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {\n\t\tt.Fatal(\"expected no error, because this should silently fails, got\", err.Error())\n\t}\n\tif _, err := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 1).WithEvents(1, 1)); err != nil {\n\t\tt.Fatal(\"expected no error, because this should silently fail, got\", err.Error())\n\t}\n\t// Repair\n\tif err := store.createSchema(); err != nil {\n\t\tt.Fatal(\"schema should've been repaired\")\n\t}\n\tstore.Clear()\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\t// Break\n\t_, _ = store.db.Exec(\"DROP TABLE endpoint_results\")\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\tif _, err := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 1).WithEvents(1, 1)); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\t// Repair\n\tif err := store.createSchema(); err != nil {\n\t\tt.Fatal(\"schema should've been repaired\")\n\t}\n\tstore.Clear()\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\t// Break\n\t_, _ = store.db.Exec(\"DROP TABLE endpoint_result_conditions\")\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\t// Repair\n\tif err := store.createSchema(); err != nil {\n\t\tt.Fatal(\"schema should've been repaired\")\n\t}\n\tstore.Clear()\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\t// Break\n\t_, _ = store.db.Exec(\"DROP TABLE endpoint_uptimes\")\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {\n\t\tt.Fatal(\"expected no error, because this should silently fails, got\", err.Error())\n\t}\n\tif _, err := store.GetAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\tif _, err := store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n\tif _, err := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err == nil {\n\t\tt.Fatal(\"expected an error\")\n\t}\n}\n\nfunc TestCacheKey(t *testing.T) {\n\tscenarios := []struct {\n\t\tendpointKey      string\n\t\tparams           paging.EndpointStatusParams\n\t\toverrideCacheKey string\n\t\texpectedCacheKey string\n\t\twantErr          bool\n\t}{\n\t\t{\n\t\t\tendpointKey:      \"simple\",\n\t\t\tparams:           paging.EndpointStatusParams{EventsPage: 1, EventsPageSize: 2, ResultsPage: 3, ResultsPageSize: 4},\n\t\t\texpectedCacheKey: \"simple-1-2-3-4\",\n\t\t\twantErr:          false,\n\t\t},\n\t\t{\n\t\t\tendpointKey:      \"with-hyphen\",\n\t\t\tparams:           paging.EndpointStatusParams{EventsPage: 0, EventsPageSize: 0, ResultsPage: 1, ResultsPageSize: 20},\n\t\t\texpectedCacheKey: \"with-hyphen-0-0-1-20\",\n\t\t\twantErr:          false,\n\t\t},\n\t\t{\n\t\t\tendpointKey:      \"with-multiple-hyphens\",\n\t\t\tparams:           paging.EndpointStatusParams{EventsPage: 0, EventsPageSize: 0, ResultsPage: 2, ResultsPageSize: 20},\n\t\t\texpectedCacheKey: \"with-multiple-hyphens-0-0-2-20\",\n\t\t\twantErr:          false,\n\t\t},\n\t\t{\n\t\t\toverrideCacheKey: \"invalid-a-2-3-4\",\n\t\t\twantErr:          true,\n\t\t},\n\t\t{\n\t\t\toverrideCacheKey: \"invalid-1-a-3-4\",\n\t\t\twantErr:          true,\n\t\t},\n\t\t{\n\t\t\toverrideCacheKey: \"invalid-1-2-a-4\",\n\t\t\twantErr:          true,\n\t\t},\n\t\t{\n\t\t\toverrideCacheKey: \"invalid-1-2-3-a\",\n\t\t\twantErr:          true,\n\t\t},\n\t\t{\n\t\t\toverrideCacheKey: \"notenoughhyphen1-2-3-4\",\n\t\t\twantErr:          true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.expectedCacheKey+scenario.overrideCacheKey, func(t *testing.T) {\n\t\t\tvar cacheKey string\n\t\t\tif len(scenario.overrideCacheKey) > 0 {\n\t\t\t\tcacheKey = scenario.overrideCacheKey\n\t\t\t} else {\n\t\t\t\tcacheKey = generateCacheKey(scenario.endpointKey, &scenario.params)\n\t\t\t\tif cacheKey != scenario.expectedCacheKey {\n\t\t\t\t\tt.Errorf(\"expected %s, got %s\", scenario.expectedCacheKey, cacheKey)\n\t\t\t\t}\n\t\t\t}\n\t\t\textractedEndpointKey, extractedParams, err := extractKeyAndParamsFromCacheKey(cacheKey)\n\t\t\tif (err != nil) != scenario.wantErr {\n\t\t\t\tt.Errorf(\"expected error %v, got %v\", scenario.wantErr, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\t// If there's an error, we don't need to check the extracted values\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif extractedEndpointKey != scenario.endpointKey {\n\t\t\t\tt.Errorf(\"expected endpointKey %s, got %s\", scenario.endpointKey, extractedEndpointKey)\n\t\t\t}\n\t\t\tif extractedParams.EventsPage != scenario.params.EventsPage {\n\t\t\t\tt.Errorf(\"expected EventsPage %d, got %d\", scenario.params.EventsPage, extractedParams.EventsPage)\n\t\t\t}\n\t\t\tif extractedParams.EventsPageSize != scenario.params.EventsPageSize {\n\t\t\t\tt.Errorf(\"expected EventsPageSize %d, got %d\", scenario.params.EventsPageSize, extractedParams.EventsPageSize)\n\t\t\t}\n\t\t\tif extractedParams.ResultsPage != scenario.params.ResultsPage {\n\t\t\t\tt.Errorf(\"expected ResultsPage %d, got %d\", scenario.params.ResultsPage, extractedParams.ResultsPage)\n\t\t\t}\n\t\t\tif extractedParams.ResultsPageSize != scenario.params.ResultsPageSize {\n\t\t\t\tt.Errorf(\"expected ResultsPageSize %d, got %d\", scenario.params.ResultsPageSize, extractedParams.ResultsPageSize)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTriggeredEndpointAlertsPersistence(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestTriggeredEndpointAlertsPersistence.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\tyes, desc := false, \"description\"\n\tep := testEndpoint\n\tep.NumberOfSuccessesInARow = 0\n\talrt := &alert.Alert{\n\t\tType:             alert.TypePagerDuty,\n\t\tEnabled:          &yes,\n\t\tFailureThreshold: 4,\n\t\tSuccessThreshold: 2,\n\t\tDescription:      &desc,\n\t\tSendOnResolved:   &yes,\n\t\tTriggered:        true,\n\t\tResolveKey:       \"1234567\",\n\t}\n\t// Alert just triggered, so NumberOfSuccessesInARow is 0\n\tif err := store.UpsertTriggeredEndpointAlert(&ep, alrt); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\texists, resolveKey, numberOfSuccessesInARow, err := store.GetTriggeredEndpointAlert(&ep, alrt)\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tif !exists {\n\t\tt.Error(\"expected triggered alert to exist\")\n\t}\n\tif resolveKey != alrt.ResolveKey {\n\t\tt.Errorf(\"expected resolveKey %s, got %s\", alrt.ResolveKey, resolveKey)\n\t}\n\tif numberOfSuccessesInARow != ep.NumberOfSuccessesInARow {\n\t\tt.Errorf(\"expected persisted NumberOfSuccessesInARow to be %d, got %d\", ep.NumberOfSuccessesInARow, numberOfSuccessesInARow)\n\t}\n\t// Endpoint just had a successful evaluation, so NumberOfSuccessesInARow is now 1\n\tep.NumberOfSuccessesInARow++\n\tif err := store.UpsertTriggeredEndpointAlert(&ep, alrt); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\texists, resolveKey, numberOfSuccessesInARow, err = store.GetTriggeredEndpointAlert(&ep, alrt)\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif !exists {\n\t\tt.Error(\"expected triggered alert to exist\")\n\t}\n\tif resolveKey != alrt.ResolveKey {\n\t\tt.Errorf(\"expected resolveKey %s, got %s\", alrt.ResolveKey, resolveKey)\n\t}\n\tif numberOfSuccessesInARow != ep.NumberOfSuccessesInARow {\n\t\tt.Errorf(\"expected persisted NumberOfSuccessesInARow to be %d, got %d\", ep.NumberOfSuccessesInARow, numberOfSuccessesInARow)\n\t}\n\t// Simulate the endpoint having another successful evaluation, which means the alert is now resolved,\n\t// and we should delete the triggered alert from the store\n\tep.NumberOfSuccessesInARow++\n\tif err := store.DeleteTriggeredEndpointAlert(&ep, alrt); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\texists, _, _, err = store.GetTriggeredEndpointAlert(&ep, alrt)\n\tif err != nil {\n\t\tt.Error(\"expected no error, got\", err.Error())\n\t}\n\tif exists {\n\t\tt.Error(\"expected triggered alert to no longer exist as it has been deleted\")\n\t}\n}\n\nfunc TestStore_DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_DeleteAllTriggeredAlertsNotInChecksumsByEndpoint.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\tyes, desc := false, \"description\"\n\tep1 := testEndpoint\n\tep1.Name = \"ep1\"\n\tep2 := testEndpoint\n\tep2.Name = \"ep2\"\n\talert1 := alert.Alert{\n\t\tType:             alert.TypePagerDuty,\n\t\tEnabled:          &yes,\n\t\tFailureThreshold: 4,\n\t\tSuccessThreshold: 2,\n\t\tDescription:      &desc,\n\t\tSendOnResolved:   &yes,\n\t\tTriggered:        true,\n\t\tResolveKey:       \"1234567\",\n\t}\n\talert2 := alert1\n\talert2.Type, alert2.ResolveKey = alert.TypeSlack, \"\"\n\talert3 := alert2\n\tif err := store.UpsertTriggeredEndpointAlert(&ep1, &alert1); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tif err := store.UpsertTriggeredEndpointAlert(&ep1, &alert2); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tif err := store.UpsertTriggeredEndpointAlert(&ep2, &alert3); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tif exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert1); !exists {\n\t\tt.Error(\"expected alert1 to have been deleted\")\n\t}\n\tif exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert2); !exists {\n\t\tt.Error(\"expected alert2 to exist for ep1\")\n\t}\n\tif exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep2, &alert3); !exists {\n\t\tt.Error(\"expected alert3 to exist for ep2\")\n\t}\n\t// Now we simulate the alert configuration being updated, and the alert being resolved\n\tif deleted := store.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(&ep1, []string{alert2.Checksum()}); deleted != 1 {\n\t\tt.Errorf(\"expected 1 triggered alert to be deleted, got %d\", deleted)\n\t}\n\tif exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert1); exists {\n\t\tt.Error(\"expected alert1 to have been deleted\")\n\t}\n\tif exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert2); !exists {\n\t\tt.Error(\"expected alert2 to exist for ep1\")\n\t}\n\tif exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep2, &alert3); !exists {\n\t\tt.Error(\"expected alert3 to exist for ep2\")\n\t}\n\t// Now let's just assume all alerts for ep1 were removed\n\tif deleted := store.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(&ep1, []string{}); deleted != 1 {\n\t\tt.Errorf(\"expected 1 triggered alert to be deleted, got %d\", deleted)\n\t}\n\t// Make sure the alert for ep2 still exists\n\tif exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep2, &alert3); !exists {\n\t\tt.Error(\"expected alert3 to exist for ep2\")\n\t}\n}\n\nfunc TestStore_HasEndpointStatusNewerThan(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/TestStore_HasEndpointStatusNewerThan.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tdefer store.Close()\n\t// InsertEndpointResult an endpoint status\n\tif err := store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult); err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\t// Check if it has a status newer than 1 hour ago\n\thasNewerStatus, err := store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-time.Hour))\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tif !hasNewerStatus {\n\t\tt.Error(\"expected to have a newer status\")\n\t}\n\t// Check if it has a status newer than 2 days ago\n\thasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(-48*time.Hour))\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tif !hasNewerStatus {\n\t\tt.Error(\"expected to have a newer status\")\n\t}\n\t// Check if there's a status newer than 1 hour in the future (silly test, but it should work)\n\thasNewerStatus, err = store.HasEndpointStatusNewerThan(testEndpoint.Key(), time.Now().Add(time.Hour))\n\tif err != nil {\n\t\tt.Fatal(\"expected no error, got\", err.Error())\n\t}\n\tif hasNewerStatus {\n\t\tt.Error(\"expected not to have a newer status in the future\")\n\t}\n}\n\n// TestEventOrderingFix specifically tests the SQL ordering fix for issue #1040\n// This test verifies that getEndpointEventsByEndpointID returns the most recent events\n// in chronological order (oldest to newest)\nfunc TestEventOrderingFix(t *testing.T) {\n\tstore, _ := NewStore(\"sqlite\", t.TempDir()+\"/test.db\", false, 100, 100)\n\tdefer store.Close()\n\tep := &endpoint.Endpoint{\n\t\tName:  \"ordering-test\",\n\t\tGroup: \"test\",\n\t\tURL:   \"https://example.com\",\n\t}\n\t// Create many events over time\n\tbaseTime := time.Now().Add(-100 * time.Hour) // Start 100 hours ago\n\tfor i := range 50 {\n\t\tresult := &endpoint.Result{\n\t\t\tSuccess:   i%2 == 0, // Alternate between true/false to create events\n\t\t\tTimestamp: baseTime.Add(time.Duration(i) * time.Hour),\n\t\t}\n\t\terr := store.InsertEndpointResult(ep, result)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to insert result %d: %v\", i, err)\n\t\t}\n\t}\n\t// Now retrieve events with pagination to test the ordering\n\ttx, _ := store.db.Begin()\n\tendpointID, _, _, _ := store.getEndpointIDGroupAndNameByKey(tx, ep.Key())\n\t// Get the first page (should get the MOST RECENT events, but in chronological order)\n\tevents, err := store.getEndpointEventsByEndpointID(tx, endpointID, 1, 10)\n\ttx.Commit()\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to get events: %v\", err)\n\t}\n\tif len(events) != 10 {\n\t\tt.Errorf(\"Expected 10 events, got %d\", len(events))\n\t}\n\t// Verify the events are in chronological order (oldest to newest)\n\tfor i := 1; i < len(events); i++ {\n\t\tif events[i].Timestamp.Before(events[i-1].Timestamp) {\n\t\t\tt.Errorf(\"Events not in chronological order: event %d timestamp %v is before event %d timestamp %v\",\n\t\t\t\ti, events[i].Timestamp, i-1, events[i-1].Timestamp)\n\t\t}\n\t}\n\t// Verify these are the most recent events\n\t// The last event in the returned list should be close to \"now\" (within the last few events we created)\n\tlastEventTime := events[len(events)-1].Timestamp\n\texpectedRecentTime := baseTime.Add(49 * time.Hour) // The most recent event we created\n\ttimeDiff := expectedRecentTime.Sub(lastEventTime)\n\tif timeDiff > 10*time.Hour { // Allow some margin for events\n\t\tt.Errorf(\"Events are not the most recent ones. Last event time: %v, expected around: %v (diff: %v)\",\n\t\t\tlastEventTime, expectedRecentTime, timeDiff)\n\t}\n\tt.Logf(\"Successfully retrieved %d most recent events in chronological order\", len(events))\n\tt.Logf(\"First event: %s at %v\", events[0].Type, events[0].Timestamp)\n\tt.Logf(\"Last event: %s at %v\", events[len(events)-1].Type, events[len(events)-1].Timestamp)\n}\n"
  },
  {
    "path": "storage/store/store.go",
    "content": "package store\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n\t\"github.com/TwiN/gatus/v5/storage/store/memory\"\n\t\"github.com/TwiN/gatus/v5/storage/store/sql\"\n\t\"github.com/TwiN/logr\"\n)\n\n// Store is the interface that each store should implement\ntype Store interface {\n\t// GetAllEndpointStatuses returns the JSON encoding of all monitored endpoint.Status\n\t// with a subset of endpoint.Result defined by the page and pageSize parameters\n\tGetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error)\n\n\t// GetAllSuiteStatuses returns all monitored suite statuses\n\tGetAllSuiteStatuses(params *paging.SuiteStatusParams) ([]*suite.Status, error)\n\n\t// GetEndpointStatus returns the endpoint status for a given endpoint name in the given group\n\tGetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*endpoint.Status, error)\n\n\t// GetEndpointStatusByKey returns the endpoint status for a given key\n\tGetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error)\n\n\t// GetSuiteStatusByKey returns the suite status for a given key\n\tGetSuiteStatusByKey(key string, params *paging.SuiteStatusParams) (*suite.Status, error)\n\n\t// GetUptimeByKey returns the uptime percentage during a time range\n\tGetUptimeByKey(key string, from, to time.Time) (float64, error)\n\n\t// GetAverageResponseTimeByKey returns the average response time in milliseconds (value) during a time range\n\tGetAverageResponseTimeByKey(key string, from, to time.Time) (int, error)\n\n\t// GetHourlyAverageResponseTimeByKey returns a map of hourly (key) average response time in milliseconds (value) during a time range\n\tGetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error)\n\n\t// InsertEndpointResult adds the observed result for the specified endpoint into the store\n\tInsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Result) error\n\n\t// InsertSuiteResult adds the observed result for the specified suite into the store\n\tInsertSuiteResult(s *suite.Suite, result *suite.Result) error\n\n\t// DeleteAllEndpointStatusesNotInKeys removes all Status that are not within the keys provided\n\t//\n\t// Used to delete endpoints that have been persisted but are no longer part of the configured endpoints\n\tDeleteAllEndpointStatusesNotInKeys(keys []string) int\n\n\t// DeleteAllSuiteStatusesNotInKeys removes all suite statuses that are not within the keys provided\n\tDeleteAllSuiteStatusesNotInKeys(keys []string) int\n\n\t// GetTriggeredEndpointAlert returns whether the triggered alert for the specified endpoint as well as the necessary information to resolve it\n\tGetTriggeredEndpointAlert(ep *endpoint.Endpoint, alert *alert.Alert) (exists bool, resolveKey string, numberOfSuccessesInARow int, err error)\n\n\t// UpsertTriggeredEndpointAlert inserts/updates a triggered alert for an endpoint\n\t// Used for persistence of triggered alerts across application restarts\n\tUpsertTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error\n\n\t// DeleteTriggeredEndpointAlert deletes a triggered alert for an endpoint\n\tDeleteTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error\n\n\t// DeleteAllTriggeredAlertsNotInChecksumsByEndpoint removes all triggered alerts owned by an endpoint whose alert\n\t// configurations are not provided in the checksums list.\n\t// This prevents triggered alerts that have been removed or modified from lingering in the database.\n\tDeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int\n\n\t// HasEndpointStatusNewerThan checks whether an endpoint has a status newer than the provided timestamp\n\tHasEndpointStatusNewerThan(key string, timestamp time.Time) (bool, error)\n\n\t// Clear deletes everything from the store\n\tClear()\n\n\t// Save persists the data if and where it needs to be persisted\n\tSave() error\n\n\t// Close terminates every connection and closes the store, if applicable.\n\t// Should only be used before stopping the application.\n\tClose()\n}\n\n// TODO: add method to check state of store (by keeping track of silent errors)\n\nvar (\n\t// Validate interface implementation on compile\n\t_ Store = (*memory.Store)(nil)\n\t_ Store = (*sql.Store)(nil)\n)\n\nvar (\n\tstore Store\n\n\t// initialized keeps track of whether the storage provider was initialized\n\t// Because store.Store is an interface, a nil check wouldn't be sufficient, so instead of doing reflection\n\t// every single time Get is called, we'll just lazily keep track of its existence through this variable\n\tinitialized bool\n\n\tctx        context.Context\n\tcancelFunc context.CancelFunc\n)\n\nfunc Get() Store {\n\tif !initialized {\n\t\t// This only happens in tests\n\t\tlogr.Info(\"[store.Get] Provider requested before it was initialized, automatically initializing\")\n\t\terr := Initialize(nil)\n\t\tif err != nil {\n\t\t\tpanic(\"failed to automatically initialize store: \" + err.Error())\n\t\t}\n\t}\n\treturn store\n}\n\n// Initialize instantiates the storage provider based on the Config provider\nfunc Initialize(cfg *storage.Config) error {\n\tinitialized = true\n\tvar err error\n\tif cancelFunc != nil {\n\t\t// Stop the active autoSave task, if there's already one\n\t\tcancelFunc()\n\t}\n\tif cfg == nil {\n\t\t// This only happens in tests\n\t\tlogr.Warn(\"[store.Initialize] nil storage config passed as parameter. This should only happen in tests. Defaulting to an empty config.\")\n\t\tcfg = &storage.Config{\n\t\t\tMaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,\n\t\t\tMaximumNumberOfEvents:  storage.DefaultMaximumNumberOfEvents,\n\t\t}\n\t}\n\tif len(cfg.Path) == 0 && cfg.Type != storage.TypePostgres {\n\t\tlogr.Infof(\"[store.Initialize] Creating storage provider of type=%s\", cfg.Type)\n\t}\n\tctx, cancelFunc = context.WithCancel(context.Background())\n\tswitch cfg.Type {\n\tcase storage.TypeSQLite, storage.TypePostgres:\n\t\tstore, err = sql.NewStore(string(cfg.Type), cfg.Path, cfg.Caching, cfg.MaximumNumberOfResults, cfg.MaximumNumberOfEvents)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\tcase storage.TypeMemory:\n\t\tfallthrough\n\tdefault:\n\t\tstore, _ = memory.NewStore(cfg.MaximumNumberOfResults, cfg.MaximumNumberOfEvents)\n\t}\n\treturn nil\n}\n\n// autoSave automatically calls the Save function of the provider at every interval\nfunc autoSave(ctx context.Context, store Store, interval time.Duration) {\n\tticker := time.NewTicker(interval)\n\tdefer ticker.Stop()\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tlogr.Info(\"[store.autoSave] Stopping active job\")\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tlogr.Info(\"[store.autoSave] Saving\")\n\t\t\tif err := store.Save(); err != nil {\n\t\t\t\tlogr.Errorf(\"[store.autoSave] Save failed: %s\", err.Error())\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "storage/store/store_bench_test.go",
    "content": "package store\n\nimport (\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n\t\"github.com/TwiN/gatus/v5/storage/store/memory\"\n\t\"github.com/TwiN/gatus/v5/storage/store/sql\"\n)\n\nfunc BenchmarkStore_GetAllEndpointStatuses(b *testing.B) {\n\tmemoryStore, err := memory.NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tb.Fatal(\"failed to create store:\", err.Error())\n\t}\n\tsqliteStore, err := sql.NewStore(\"sqlite\", b.TempDir()+\"/BenchmarkStore_GetAllEndpointStatuses.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tb.Fatal(\"failed to create store:\", err.Error())\n\t}\n\tdefer sqliteStore.Close()\n\ttype Scenario struct {\n\t\tName     string\n\t\tStore    Store\n\t\tParallel bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:     \"memory\",\n\t\t\tStore:    memoryStore,\n\t\t\tParallel: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"memory-parallel\",\n\t\t\tStore:    memoryStore,\n\t\t\tParallel: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"sqlite\",\n\t\t\tStore:    sqliteStore,\n\t\t\tParallel: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"sqlite-parallel\",\n\t\t\tStore:    sqliteStore,\n\t\t\tParallel: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tnumberOfEndpoints := []int{10, 25, 50, 100}\n\t\tfor _, numberOfEndpointsToCreate := range numberOfEndpoints {\n\t\t\t// Create endpoints and insert results\n\t\t\tfor i := range numberOfEndpointsToCreate {\n\t\t\t\tep := testEndpoint\n\t\t\t\tep.Name = \"endpoint\" + strconv.Itoa(i)\n\t\t\t\t// InsertEndpointResult 20 results for each endpoint\n\t\t\t\tfor range 20 {\n\t\t\t\t\tscenario.Store.InsertEndpointResult(&ep, &testSuccessfulResult)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Run the scenarios\n\t\t\tb.Run(scenario.Name+\"-with-\"+strconv.Itoa(numberOfEndpointsToCreate)+\"-endpoints\", func(b *testing.B) {\n\t\t\t\tif scenario.Parallel {\n\t\t\t\t\tb.RunParallel(func(pb *testing.PB) {\n\t\t\t\t\t\tfor pb.Next() {\n\t\t\t\t\t\t\t_, _ = scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t} else {\n\t\t\t\t\tfor n := 0; n < b.N; n++ {\n\t\t\t\t\t\t_, _ = scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tb.ReportAllocs()\n\t\t\t})\n\t\t\tscenario.Store.Clear()\n\t\t}\n\t}\n}\n\nfunc BenchmarkStore_Insert(b *testing.B) {\n\tmemoryStore, err := memory.NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tb.Fatal(\"failed to create store:\", err.Error())\n\t}\n\tsqliteStore, err := sql.NewStore(\"sqlite\", b.TempDir()+\"/BenchmarkStore_Insert.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tb.Fatal(\"failed to create store:\", err.Error())\n\t}\n\tdefer sqliteStore.Close()\n\ttype Scenario struct {\n\t\tName     string\n\t\tStore    Store\n\t\tParallel bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:     \"memory\",\n\t\t\tStore:    memoryStore,\n\t\t\tParallel: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"memory-parallel\",\n\t\t\tStore:    memoryStore,\n\t\t\tParallel: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"sqlite\",\n\t\t\tStore:    sqliteStore,\n\t\t\tParallel: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"sqlite-parallel\",\n\t\t\tStore:    sqliteStore,\n\t\t\tParallel: false,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tb.Run(scenario.Name, func(b *testing.B) {\n\t\t\tif scenario.Parallel {\n\t\t\t\tb.RunParallel(func(pb *testing.PB) {\n\t\t\t\t\tn := 0\n\t\t\t\t\tfor pb.Next() {\n\t\t\t\t\t\tvar result endpoint.Result\n\t\t\t\t\t\tif n%10 == 0 {\n\t\t\t\t\t\t\tresult = testUnsuccessfulResult\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tresult = testSuccessfulResult\n\t\t\t\t\t\t}\n\t\t\t\t\t\tresult.Timestamp = time.Now()\n\t\t\t\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &result)\n\t\t\t\t\t\tn++\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tfor n := 0; n < b.N; n++ {\n\t\t\t\t\tvar result endpoint.Result\n\t\t\t\t\tif n%10 == 0 {\n\t\t\t\t\t\tresult = testUnsuccessfulResult\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresult = testSuccessfulResult\n\t\t\t\t\t}\n\t\t\t\t\tresult.Timestamp = time.Now()\n\t\t\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &result)\n\t\t\t\t}\n\t\t\t}\n\t\t\tb.ReportAllocs()\n\t\t\tscenario.Store.Clear()\n\t\t})\n\t}\n}\n\nfunc BenchmarkStore_GetEndpointStatusByKey(b *testing.B) {\n\tmemoryStore, err := memory.NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tb.Fatal(\"failed to create store:\", err.Error())\n\t}\n\tsqliteStore, err := sql.NewStore(\"sqlite\", b.TempDir()+\"/BenchmarkStore_GetEndpointStatusByKey.db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tb.Fatal(\"failed to create store:\", err.Error())\n\t}\n\tdefer sqliteStore.Close()\n\ttype Scenario struct {\n\t\tName     string\n\t\tStore    Store\n\t\tParallel bool\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:     \"memory\",\n\t\t\tStore:    memoryStore,\n\t\t\tParallel: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"memory-parallel\",\n\t\t\tStore:    memoryStore,\n\t\t\tParallel: true,\n\t\t},\n\t\t{\n\t\t\tName:     \"sqlite\",\n\t\t\tStore:    sqliteStore,\n\t\t\tParallel: false,\n\t\t},\n\t\t{\n\t\t\tName:     \"sqlite-parallel\",\n\t\t\tStore:    sqliteStore,\n\t\t\tParallel: true,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tfor range 50 {\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)\n\t\t}\n\t\tb.Run(scenario.Name, func(b *testing.B) {\n\t\t\tif scenario.Parallel {\n\t\t\t\tb.RunParallel(func(pb *testing.PB) {\n\t\t\t\t\tfor pb.Next() {\n\t\t\t\t\t\tscenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, 20))\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tfor n := 0; n < b.N; n++ {\n\t\t\t\t\tscenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, 20))\n\t\t\t\t}\n\t\t\t}\n\t\t\tb.ReportAllocs()\n\t\t})\n\t\tscenario.Store.Clear()\n\t}\n}\n"
  },
  {
    "path": "storage/store/store_test.go",
    "content": "package store\n\nimport (\n\t\"errors\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/storage\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common\"\n\t\"github.com/TwiN/gatus/v5/storage/store/common/paging\"\n\t\"github.com/TwiN/gatus/v5/storage/store/memory\"\n\t\"github.com/TwiN/gatus/v5/storage/store/sql\"\n)\n\nvar (\n\tfirstCondition  = endpoint.Condition(\"[STATUS] == 200\")\n\tsecondCondition = endpoint.Condition(\"[RESPONSE_TIME] < 500\")\n\tthirdCondition  = endpoint.Condition(\"[CERTIFICATE_EXPIRATION] < 72h\")\n\n\tnow = time.Now().Truncate(time.Hour)\n\n\ttestEndpoint = endpoint.Endpoint{\n\t\tName:                    \"name\",\n\t\tGroup:                   \"group\",\n\t\tURL:                     \"https://example.org/what/ever\",\n\t\tMethod:                  \"GET\",\n\t\tBody:                    \"body\",\n\t\tInterval:                30 * time.Second,\n\t\tConditions:              []endpoint.Condition{firstCondition, secondCondition, thirdCondition},\n\t\tAlerts:                  nil,\n\t\tNumberOfFailuresInARow:  0,\n\t\tNumberOfSuccessesInARow: 0,\n\t}\n\ttestSuccessfulResult = endpoint.Result{\n\t\tTimestamp:             now,\n\t\tSuccess:               true,\n\t\tHostname:              \"example.org\",\n\t\tIP:                    \"127.0.0.1\",\n\t\tHTTPStatus:            200,\n\t\tErrors:                nil,\n\t\tConnected:             true,\n\t\tDuration:              150 * time.Millisecond,\n\t\tCertificateExpiration: 10 * time.Hour,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{\n\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[CERTIFICATE_EXPIRATION] < 72h\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t},\n\t}\n\ttestUnsuccessfulResult = endpoint.Result{\n\t\tTimestamp:             now,\n\t\tSuccess:               false,\n\t\tHostname:              \"example.org\",\n\t\tIP:                    \"127.0.0.1\",\n\t\tHTTPStatus:            200,\n\t\tErrors:                []string{\"error-1\", \"error-2\"},\n\t\tConnected:             true,\n\t\tDuration:              750 * time.Millisecond,\n\t\tCertificateExpiration: 10 * time.Hour,\n\t\tConditionResults: []*endpoint.ConditionResult{\n\t\t\t{\n\t\t\t\tCondition: \"[STATUS] == 200\",\n\t\t\t\tSuccess:   true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[RESPONSE_TIME] < 500\",\n\t\t\t\tSuccess:   false,\n\t\t\t},\n\t\t\t{\n\t\t\t\tCondition: \"[CERTIFICATE_EXPIRATION] < 72h\",\n\t\t\t\tSuccess:   false,\n\t\t\t},\n\t\t},\n\t}\n)\n\ntype Scenario struct {\n\tName  string\n\tStore Store\n}\n\nfunc initStoresAndBaseScenarios(t *testing.T, testName string) []*Scenario {\n\tmemoryStore, err := memory.NewStore(storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tt.Fatal(\"failed to create store:\", err.Error())\n\t}\n\tsqliteStore, err := sql.NewStore(\"sqlite\", t.TempDir()+\"/\"+testName+\".db\", false, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tt.Fatal(\"failed to create store:\", err.Error())\n\t}\n\tsqliteStoreWithCaching, err := sql.NewStore(\"sqlite\", t.TempDir()+\"/\"+testName+\"-with-caching.db\", true, storage.DefaultMaximumNumberOfResults, storage.DefaultMaximumNumberOfEvents)\n\tif err != nil {\n\t\tt.Fatal(\"failed to create store:\", err.Error())\n\t}\n\treturn []*Scenario{\n\t\t{\n\t\t\tName:  \"memory\",\n\t\t\tStore: memoryStore,\n\t\t},\n\t\t{\n\t\t\tName:  \"sqlite\",\n\t\t\tStore: sqliteStore,\n\t\t},\n\t\t{\n\t\t\tName:  \"sqlite-with-caching\",\n\t\t\tStore: sqliteStoreWithCaching,\n\t\t},\n\t}\n}\n\nfunc cleanUp(scenarios []*Scenario) {\n\tfor _, scenario := range scenarios {\n\t\tscenario.Store.Close()\n\t}\n}\n\nfunc TestStore_GetEndpointStatusByKey(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_GetEndpointStatusByKey\")\n\tdefer cleanUp(scenarios)\n\tfirstResult := testSuccessfulResult\n\tfirstResult.Timestamp = now.Add(-time.Minute)\n\tsecondResult := testUnsuccessfulResult\n\tsecondResult.Timestamp = now\n\tthirdResult := testSuccessfulResult\n\tthirdResult.Timestamp = now\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)\n\t\t\tendpointStatus, err := scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(\"shouldn't have returned an error, got\", err.Error())\n\t\t\t}\n\t\t\tif endpointStatus == nil {\n\t\t\t\tt.Fatalf(\"endpointStatus shouldn't have been nil\")\n\t\t\t}\n\t\t\tif endpointStatus.Name != testEndpoint.Name {\n\t\t\t\tt.Fatalf(\"endpointStatus.Name should've been %s, got %s\", testEndpoint.Name, endpointStatus.Name)\n\t\t\t}\n\t\t\tif endpointStatus.Group != testEndpoint.Group {\n\t\t\t\tt.Fatalf(\"endpointStatus.Group should've been %s, got %s\", testEndpoint.Group, endpointStatus.Group)\n\t\t\t}\n\t\t\tif len(endpointStatus.Results) != 2 {\n\t\t\t\tt.Fatalf(\"endpointStatus.Results should've had 2 entries\")\n\t\t\t}\n\t\t\tif endpointStatus.Results[0].Timestamp.After(endpointStatus.Results[1].Timestamp) {\n\t\t\t\tt.Error(\"The result at index 0 should've been older than the result at index 1\")\n\t\t\t}\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &thirdResult)\n\t\t\tendpointStatus, err = scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(\"shouldn't have returned an error, got\", err.Error())\n\t\t\t}\n\t\t\tif len(endpointStatus.Results) != 3 {\n\t\t\t\tt.Fatalf(\"endpointStatus.Results should've had 3 entries\")\n\t\t\t}\n\t\t\tscenario.Store.Clear()\n\t\t})\n\t}\n}\n\nfunc TestStore_GetEndpointStatusForMissingStatusReturnsNil(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_GetEndpointStatusForMissingStatusReturnsNil\")\n\tdefer cleanUp(scenarios)\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\t\t\tendpointStatus, err := scenario.Store.GetEndpointStatus(\"nonexistantgroup\", \"nonexistantname\", paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))\n\t\t\tif !errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\t\tt.Error(\"should've returned ErrEndpointNotFound, got\", err)\n\t\t\t}\n\t\t\tif endpointStatus != nil {\n\t\t\t\tt.Errorf(\"Returned endpoint status for group '%s' and name '%s' not nil after inserting the endpoint into the store\", testEndpoint.Group, testEndpoint.Name)\n\t\t\t}\n\t\t\tendpointStatus, err = scenario.Store.GetEndpointStatus(testEndpoint.Group, \"nonexistantname\", paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))\n\t\t\tif !errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\t\tt.Error(\"should've returned ErrEndpointNotFound, got\", err)\n\t\t\t}\n\t\t\tif endpointStatus != nil {\n\t\t\t\tt.Errorf(\"Returned endpoint status for group '%s' and name '%s' not nil after inserting the endpoint into the store\", testEndpoint.Group, \"nonexistantname\")\n\t\t\t}\n\t\t\tendpointStatus, err = scenario.Store.GetEndpointStatus(\"nonexistantgroup\", testEndpoint.Name, paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))\n\t\t\tif !errors.Is(err, common.ErrEndpointNotFound) {\n\t\t\t\tt.Error(\"should've returned ErrEndpointNotFound, got\", err)\n\t\t\t}\n\t\t\tif endpointStatus != nil {\n\t\t\t\tt.Errorf(\"Returned endpoint status for group '%s' and name '%s' not nil after inserting the endpoint into the store\", \"nonexistantgroup\", testEndpoint.Name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestStore_GetAllEndpointStatuses(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_GetAllEndpointStatuses\")\n\tdefer cleanUp(scenarios)\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &testUnsuccessfulResult)\n\t\t\tendpointStatuses, err := scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20))\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err.Error())\n\t\t\t}\n\t\t\tif len(endpointStatuses) != 1 {\n\t\t\t\tt.Fatal(\"expected 1 endpoint status\")\n\t\t\t}\n\t\t\tactual := endpointStatuses[0]\n\t\t\tif actual == nil {\n\t\t\t\tt.Fatal(\"expected endpoint status to exist\")\n\t\t\t}\n\t\t\tif len(actual.Results) != 2 {\n\t\t\t\tt.Error(\"expected 2 results, got\", len(actual.Results))\n\t\t\t}\n\t\t\tif len(actual.Events) != 0 {\n\t\t\t\tt.Error(\"expected 0 events, got\", len(actual.Events))\n\t\t\t}\n\t\t\tscenario.Store.Clear()\n\t\t})\n\t\tt.Run(scenario.Name+\"-page-2\", func(t *testing.T) {\n\t\t\totherEndpoint := testEndpoint\n\t\t\totherEndpoint.Name = testEndpoint.Name + \"-other\"\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &testSuccessfulResult)\n\t\t\tscenario.Store.InsertEndpointResult(&otherEndpoint, &testSuccessfulResult)\n\t\t\tscenario.Store.InsertEndpointResult(&otherEndpoint, &testSuccessfulResult)\n\t\t\tscenario.Store.InsertEndpointResult(&otherEndpoint, &testSuccessfulResult)\n\t\t\tendpointStatuses, err := scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(2, 2))\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err.Error())\n\t\t\t}\n\t\t\tif len(endpointStatuses) != 2 {\n\t\t\t\tt.Fatal(\"expected 2 endpoint statuses\")\n\t\t\t}\n\t\t\tif endpointStatuses[0] == nil || endpointStatuses[1] == nil {\n\t\t\t\tt.Fatal(\"expected endpoint status to exist\")\n\t\t\t}\n\t\t\tif len(endpointStatuses[0].Results) != 0 {\n\t\t\t\tt.Error(\"expected 0 results on the first endpoint, got\", len(endpointStatuses[0].Results))\n\t\t\t}\n\t\t\tif len(endpointStatuses[1].Results) != 1 {\n\t\t\t\tt.Error(\"expected 1 result on the second endpoint, got\", len(endpointStatuses[1].Results))\n\t\t\t}\n\t\t\tif len(endpointStatuses[0].Events) != 0 {\n\t\t\t\tt.Error(\"expected 0 events on the first endpoint, got\", len(endpointStatuses[0].Events))\n\t\t\t}\n\t\t\tif len(endpointStatuses[1].Events) != 0 {\n\t\t\t\tt.Error(\"expected 0 events on the second endpoint, got\", len(endpointStatuses[1].Events))\n\t\t\t}\n\t\t\tscenario.Store.Clear()\n\t\t})\n\t}\n}\n\nfunc TestStore_GetAllEndpointStatusesWithResultsAndEvents(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_GetAllEndpointStatusesWithResultsAndEvents\")\n\tdefer cleanUp(scenarios)\n\tfirstResult := testSuccessfulResult\n\tsecondResult := testUnsuccessfulResult\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)\n\t\t\t// Can't be bothered dealing with timezone issues on the worker that runs the automated tests\n\t\t\tendpointStatuses, err := scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20).WithEvents(1, 50))\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err.Error())\n\t\t\t}\n\t\t\tif len(endpointStatuses) != 1 {\n\t\t\t\tt.Fatal(\"expected 1 endpoint status\")\n\t\t\t}\n\t\t\tactual := endpointStatuses[0]\n\t\t\tif actual == nil {\n\t\t\t\tt.Fatal(\"expected endpoint status to exist\")\n\t\t\t}\n\t\t\tif len(actual.Results) != 2 {\n\t\t\t\tt.Error(\"expected 2 results, got\", len(actual.Results))\n\t\t\t}\n\t\t\tif len(actual.Events) != 3 {\n\t\t\t\tt.Error(\"expected 3 events, got\", len(actual.Events))\n\t\t\t}\n\t\t\tscenario.Store.Clear()\n\t\t})\n\t}\n}\n\nfunc TestStore_GetEndpointStatusPage1IsHasMoreRecentResultsThanPage2(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_GetEndpointStatusPage1IsHasMoreRecentResultsThanPage2\")\n\tdefer cleanUp(scenarios)\n\tfirstResult := testSuccessfulResult\n\tfirstResult.Timestamp = now.Add(-time.Minute)\n\tsecondResult := testUnsuccessfulResult\n\tsecondResult.Timestamp = now\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)\n\t\t\tendpointStatusPage1, err := scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(1, 1))\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err.Error())\n\t\t\t}\n\t\t\tif endpointStatusPage1 == nil {\n\t\t\t\tt.Fatalf(\"endpointStatusPage1 shouldn't have been nil\")\n\t\t\t}\n\t\t\tif len(endpointStatusPage1.Results) != 1 {\n\t\t\t\tt.Fatalf(\"endpointStatusPage1 should've had 1 result\")\n\t\t\t}\n\t\t\tendpointStatusPage2, err := scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithResults(2, 1))\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err.Error())\n\t\t\t}\n\t\t\tif endpointStatusPage2 == nil {\n\t\t\t\tt.Fatalf(\"endpointStatusPage2 shouldn't have been nil\")\n\t\t\t}\n\t\t\tif len(endpointStatusPage2.Results) != 1 {\n\t\t\t\tt.Fatalf(\"endpointStatusPage2 should've had 1 result\")\n\t\t\t}\n\t\t\t// Compare the timestamp of both pages\n\t\t\tif !endpointStatusPage1.Results[0].Timestamp.After(endpointStatusPage2.Results[0].Timestamp) {\n\t\t\t\tt.Errorf(\"The result from the first page should've been more recent than the results from the second page\")\n\t\t\t}\n\t\t\tscenario.Store.Clear()\n\t\t})\n\t}\n}\n\nfunc TestStore_GetUptimeByKey(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_GetUptimeByKey\")\n\tdefer cleanUp(scenarios)\n\tfirstResult := testSuccessfulResult\n\tfirstResult.Timestamp = now.Add(-time.Minute)\n\tsecondResult := testUnsuccessfulResult\n\tsecondResult.Timestamp = now\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tif _, err := scenario.Store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour), time.Now()); err != common.ErrEndpointNotFound {\n\t\t\t\tt.Errorf(\"should've returned not found because there's nothing yet, got %v\", err)\n\t\t\t}\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)\n\t\t\tif uptime, _ := scenario.Store.GetUptimeByKey(testEndpoint.Key(), now.Add(-time.Hour), time.Now()); uptime != 0.5 {\n\t\t\t\tt.Errorf(\"the uptime over the past 1h should've been 0.5, got %f\", uptime)\n\t\t\t}\n\t\t\tif uptime, _ := scenario.Store.GetUptimeByKey(testEndpoint.Key(), now.Add(-time.Hour*24), time.Now()); uptime != 0.5 {\n\t\t\t\tt.Errorf(\"the uptime over the past 24h should've been 0.5, got %f\", uptime)\n\t\t\t}\n\t\t\tif uptime, _ := scenario.Store.GetUptimeByKey(testEndpoint.Key(), now.Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {\n\t\t\t\tt.Errorf(\"the uptime over the past 7d should've been 0.5, got %f\", uptime)\n\t\t\t}\n\t\t\tif _, err := scenario.Store.GetUptimeByKey(testEndpoint.Key(), now, time.Now().Add(-time.Hour)); err == nil {\n\t\t\t\tt.Error(\"should've returned an error because the parameter 'from' cannot be older than 'to'\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestStore_GetAverageResponseTimeByKey(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_GetAverageResponseTimeByKey\")\n\tdefer cleanUp(scenarios)\n\tfirstResult := testSuccessfulResult\n\tfirstResult.Timestamp = now.Add(-(2 * time.Hour))\n\tfirstResult.Duration = 300 * time.Millisecond\n\tsecondResult := testSuccessfulResult\n\tsecondResult.Duration = 150 * time.Millisecond\n\tsecondResult.Timestamp = now.Add(-(1*time.Hour + 30*time.Minute))\n\tthirdResult := testUnsuccessfulResult\n\tthirdResult.Duration = 200 * time.Millisecond\n\tthirdResult.Timestamp = now.Add(-(1 * time.Hour))\n\tfourthResult := testSuccessfulResult\n\tfourthResult.Duration = 500 * time.Millisecond\n\tfourthResult.Timestamp = now\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &thirdResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &fourthResult)\n\t\t\tif averageResponseTime, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-48*time.Hour), now.Add(-24*time.Hour)); err == nil {\n\t\t\t\tif averageResponseTime != 0 {\n\t\t\t\t\tt.Errorf(\"expected average response time to be 0ms, got %v\", averageResponseTime)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err)\n\t\t\t}\n\t\t\tif averageResponseTime, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-24*time.Hour), now); err == nil {\n\t\t\t\tif averageResponseTime != 287 {\n\t\t\t\t\tt.Errorf(\"expected average response time to be 287ms, got %v\", averageResponseTime)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err)\n\t\t\t}\n\t\t\tif averageResponseTime, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-time.Hour), now); err == nil {\n\t\t\t\tif averageResponseTime != 350 {\n\t\t\t\t\tt.Errorf(\"expected average response time to be 350ms, got %v\", averageResponseTime)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err)\n\t\t\t}\n\t\t\tif averageResponseTime, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-2*time.Hour), now.Add(-time.Hour)); err == nil {\n\t\t\t\tif averageResponseTime != 216 {\n\t\t\t\t\tt.Errorf(\"expected average response time to be 216ms, got %v\", averageResponseTime)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err)\n\t\t\t}\n\t\t\tif _, err := scenario.Store.GetAverageResponseTimeByKey(testEndpoint.Key(), now, now.Add(-2*time.Hour)); err == nil {\n\t\t\t\tt.Error(\"expected an error because from > to, got nil\")\n\t\t\t}\n\t\t\tscenario.Store.Clear()\n\t\t})\n\t}\n}\n\nfunc TestStore_GetHourlyAverageResponseTimeByKey(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_GetHourlyAverageResponseTimeByKey\")\n\tdefer cleanUp(scenarios)\n\tfirstResult := testSuccessfulResult\n\tfirstResult.Timestamp = now.Add(-(2 * time.Hour))\n\tfirstResult.Duration = 300 * time.Millisecond\n\tsecondResult := testSuccessfulResult\n\tsecondResult.Duration = 150 * time.Millisecond\n\tsecondResult.Timestamp = now.Add(-(1*time.Hour + 30*time.Minute))\n\tthirdResult := testUnsuccessfulResult\n\tthirdResult.Duration = 200 * time.Millisecond\n\tthirdResult.Timestamp = now.Add(-(1 * time.Hour))\n\tfourthResult := testSuccessfulResult\n\tfourthResult.Duration = 500 * time.Millisecond\n\tfourthResult.Timestamp = now\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &thirdResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &fourthResult)\n\t\t\thourlyAverageResponseTime, err := scenario.Store.GetHourlyAverageResponseTimeByKey(testEndpoint.Key(), now.Add(-24*time.Hour), now)\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err)\n\t\t\t}\n\t\t\tif key := now.Truncate(time.Hour).Unix(); hourlyAverageResponseTime[key] != 500 {\n\t\t\t\tt.Errorf(\"expected average response time to be 500ms at %d, got %v\", key, hourlyAverageResponseTime[key])\n\t\t\t}\n\t\t\tif key := now.Truncate(time.Hour).Add(-time.Hour).Unix(); hourlyAverageResponseTime[key] != 200 {\n\t\t\t\tt.Errorf(\"expected average response time to be 200ms at %d, got %v\", key, hourlyAverageResponseTime[key])\n\t\t\t}\n\t\t\tif key := now.Truncate(time.Hour).Add(-2 * time.Hour).Unix(); hourlyAverageResponseTime[key] != 225 {\n\t\t\t\tt.Errorf(\"expected average response time to be 225ms at %d, got %v\", key, hourlyAverageResponseTime[key])\n\t\t\t}\n\t\t\tscenario.Store.Clear()\n\t\t})\n\t}\n}\n\nfunc TestStore_Insert(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_Insert\")\n\tdefer cleanUp(scenarios)\n\tfirstResult := testSuccessfulResult\n\tfirstResult.Timestamp = now.Add(-time.Minute)\n\tsecondResult := testUnsuccessfulResult\n\tsecondResult.Timestamp = now\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &firstResult)\n\t\t\tscenario.Store.InsertEndpointResult(&testEndpoint, &secondResult)\n\t\t\tss, err := scenario.Store.GetEndpointStatusByKey(testEndpoint.Key(), paging.NewEndpointStatusParams().WithEvents(1, storage.DefaultMaximumNumberOfEvents).WithResults(1, storage.DefaultMaximumNumberOfResults))\n\t\t\tif err != nil {\n\t\t\t\tt.Error(\"shouldn't have returned an error, got\", err)\n\t\t\t}\n\t\t\tif ss == nil {\n\t\t\t\tt.Fatalf(\"Store should've had key '%s', but didn't\", testEndpoint.Key())\n\t\t\t}\n\t\t\tif len(ss.Events) != 3 {\n\t\t\t\tt.Fatalf(\"Endpoint '%s' should've had 3 events, got %d\", ss.Name, len(ss.Events))\n\t\t\t}\n\t\t\tif len(ss.Results) != 2 {\n\t\t\t\tt.Fatalf(\"Endpoint '%s' should've had 2 results, got %d\", ss.Name, len(ss.Results))\n\t\t\t}\n\t\t\tfor i, expectedResult := range []endpoint.Result{firstResult, secondResult} {\n\t\t\t\tif expectedResult.HTTPStatus != ss.Results[i].HTTPStatus {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had a HTTPStatus of %d, got %d\", i, ss.Results[i].HTTPStatus, expectedResult.HTTPStatus)\n\t\t\t\t}\n\t\t\t\tif expectedResult.DNSRCode != ss.Results[i].DNSRCode {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had a DNSRCode of %s, got %s\", i, ss.Results[i].DNSRCode, expectedResult.DNSRCode)\n\t\t\t\t}\n\t\t\t\tif expectedResult.Hostname != ss.Results[i].Hostname {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had a Hostname of %s, got %s\", i, ss.Results[i].Hostname, expectedResult.Hostname)\n\t\t\t\t}\n\t\t\t\tif expectedResult.IP != ss.Results[i].IP {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had a IP of %s, got %s\", i, ss.Results[i].IP, expectedResult.IP)\n\t\t\t\t}\n\t\t\t\tif expectedResult.Connected != ss.Results[i].Connected {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had a Connected value of %t, got %t\", i, ss.Results[i].Connected, expectedResult.Connected)\n\t\t\t\t}\n\t\t\t\tif expectedResult.Duration != ss.Results[i].Duration {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had a Duration of %s, got %s\", i, ss.Results[i].Duration.String(), expectedResult.Duration.String())\n\t\t\t\t}\n\t\t\t\tif len(expectedResult.Errors) != len(ss.Results[i].Errors) {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had %d errors, but actually had %d errors\", i, len(ss.Results[i].Errors), len(expectedResult.Errors))\n\t\t\t\t} else {\n\t\t\t\t\tfor j := range expectedResult.Errors {\n\t\t\t\t\t\tif ss.Results[i].Errors[j] != expectedResult.Errors[j] {\n\t\t\t\t\t\t\tt.Error(\"should've been the same\")\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif len(expectedResult.ConditionResults) != len(ss.Results[i].ConditionResults) {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had %d ConditionResults, but actually had %d ConditionResults\", i, len(ss.Results[i].ConditionResults), len(expectedResult.ConditionResults))\n\t\t\t\t} else {\n\t\t\t\t\tfor j := range expectedResult.ConditionResults {\n\t\t\t\t\t\tif ss.Results[i].ConditionResults[j].Condition != expectedResult.ConditionResults[j].Condition {\n\t\t\t\t\t\t\tt.Error(\"should've been the same\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif ss.Results[i].ConditionResults[j].Success != expectedResult.ConditionResults[j].Success {\n\t\t\t\t\t\t\tt.Error(\"should've been the same\")\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif expectedResult.Success != ss.Results[i].Success {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had a Success of %t, got %t\", i, ss.Results[i].Success, expectedResult.Success)\n\t\t\t\t}\n\t\t\t\tif expectedResult.Timestamp.Unix() != ss.Results[i].Timestamp.Unix() {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had a Timestamp of %d, got %d\", i, ss.Results[i].Timestamp.Unix(), expectedResult.Timestamp.Unix())\n\t\t\t\t}\n\t\t\t\tif expectedResult.CertificateExpiration != ss.Results[i].CertificateExpiration {\n\t\t\t\t\tt.Errorf(\"Result at index %d should've had a CertificateExpiration of %s, got %s\", i, ss.Results[i].CertificateExpiration.String(), expectedResult.CertificateExpiration.String())\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestStore_DeleteAllEndpointStatusesNotInKeys(t *testing.T) {\n\tscenarios := initStoresAndBaseScenarios(t, \"TestStore_DeleteAllEndpointStatusesNotInKeys\")\n\tdefer cleanUp(scenarios)\n\tfirstEndpoint := endpoint.Endpoint{Name: \"endpoint-1\", Group: \"group\"}\n\tsecondEndpoint := endpoint.Endpoint{Name: \"endpoint-2\", Group: \"group\"}\n\tr := &testSuccessfulResult\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tscenario.Store.InsertEndpointResult(&firstEndpoint, r)\n\t\t\tscenario.Store.InsertEndpointResult(&secondEndpoint, r)\n\t\t\tif ss, _ := scenario.Store.GetEndpointStatusByKey(firstEndpoint.Key(), paging.NewEndpointStatusParams()); ss == nil {\n\t\t\t\tt.Fatal(\"firstEndpoint should exist, got\", ss)\n\t\t\t}\n\t\t\tif ss, _ := scenario.Store.GetEndpointStatusByKey(secondEndpoint.Key(), paging.NewEndpointStatusParams()); ss == nil {\n\t\t\t\tt.Fatal(\"secondEndpoint should exist, got\", ss)\n\t\t\t}\n\t\t\tscenario.Store.DeleteAllEndpointStatusesNotInKeys([]string{firstEndpoint.Key()})\n\t\t\tif ss, _ := scenario.Store.GetEndpointStatusByKey(firstEndpoint.Key(), paging.NewEndpointStatusParams()); ss == nil {\n\t\t\t\tt.Error(\"secondEndpoint should still exist, got\", ss)\n\t\t\t}\n\t\t\tif ss, _ := scenario.Store.GetEndpointStatusByKey(secondEndpoint.Key(), paging.NewEndpointStatusParams()); ss != nil {\n\t\t\t\tt.Error(\"firstEndpoint should have been deleted, got\", ss)\n\t\t\t}\n\t\t\t// Delete everything\n\t\t\tscenario.Store.DeleteAllEndpointStatusesNotInKeys([]string{})\n\t\t\tendpointStatuses, _ := scenario.Store.GetAllEndpointStatuses(paging.NewEndpointStatusParams())\n\t\t\tif len(endpointStatuses) != 0 {\n\t\t\t\tt.Errorf(\"everything should've been deleted\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGet(t *testing.T) {\n\tstore := Get()\n\tif store == nil {\n\t\tt.Error(\"store should've been automatically initialized\")\n\t}\n}\n\nfunc TestInitialize(t *testing.T) {\n\tdir := t.TempDir()\n\ttype Scenario struct {\n\t\tName        string\n\t\tCfg         *storage.Config\n\t\tExpectedErr error\n\t}\n\tscenarios := []Scenario{\n\t\t{\n\t\t\tName:        \"nil\",\n\t\t\tCfg:         nil,\n\t\t\tExpectedErr: nil,\n\t\t},\n\t\t{\n\t\t\tName:        \"blank\",\n\t\t\tCfg:         &storage.Config{},\n\t\t\tExpectedErr: nil,\n\t\t},\n\t\t{\n\t\t\tName:        \"memory-no-path\",\n\t\t\tCfg:         &storage.Config{Type: storage.TypeMemory},\n\t\t\tExpectedErr: nil,\n\t\t},\n\t\t{\n\t\t\tName:        \"sqlite-no-path\",\n\t\t\tCfg:         &storage.Config{Type: storage.TypeSQLite},\n\t\t\tExpectedErr: sql.ErrPathNotSpecified,\n\t\t},\n\t\t{\n\t\t\tName:        \"sqlite-with-path\",\n\t\t\tCfg:         &storage.Config{Type: storage.TypeSQLite, Path: filepath.Join(dir, \"TestInitialize_sqlite-with-path.db\")},\n\t\t\tExpectedErr: nil,\n\t\t},\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\terr := Initialize(scenario.Cfg)\n\t\t\tif err != scenario.ExpectedErr {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.ExpectedErr, err)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif cancelFunc == nil {\n\t\t\t\tt.Error(\"cancelFunc shouldn't have been nil\")\n\t\t\t}\n\t\t\tif ctx == nil {\n\t\t\t\tt.Error(\"ctx shouldn't have been nil\")\n\t\t\t}\n\t\t\tif store == nil {\n\t\t\t\tt.Fatal(\"provider shouldn't have been nit\")\n\t\t\t}\n\t\t\tstore.Close()\n\t\t\t// Try to initialize it again\n\t\t\terr = Initialize(scenario.Cfg)\n\t\t\tif !errors.Is(err, scenario.ExpectedErr) {\n\t\t\t\tt.Errorf(\"expected %v, got %v\", scenario.ExpectedErr, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstore.Close()\n\t\t})\n\t}\n}\n\nfunc TestAutoSave(t *testing.T) {\n\tfile := filepath.Join(t.TempDir(), \"/TestAutoSave.db\")\n\tif err := Initialize(&storage.Config{Path: file}); err != nil {\n\t\tt.Fatal(\"shouldn't have returned an error\")\n\t}\n\tgo autoSave(ctx, store, 3*time.Millisecond)\n\ttime.Sleep(15 * time.Millisecond)\n\tcancelFunc()\n\ttime.Sleep(50 * time.Millisecond)\n}\n"
  },
  {
    "path": "storage/type.go",
    "content": "package storage\n\n// Type of the store.\ntype Type string\n\nconst (\n\tTypeMemory   Type = \"memory\"   // In-memory store\n\tTypeSQLite   Type = \"sqlite\"   // SQLite store\n\tTypePostgres Type = \"postgres\" // Postgres store\n)\n"
  },
  {
    "path": "test/mock.go",
    "content": "package test\n\nimport \"net/http\"\n\ntype MockRoundTripper func(r *http.Request) *http.Response\n\nfunc (f MockRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {\n\treturn f(r), nil\n}\n"
  },
  {
    "path": "testdata/badcert.key",
    "content": "-----BEGIN PRIVATE KEY-----\nwat\n-----END PRIVATE KEY-----"
  },
  {
    "path": "testdata/badcert.pem",
    "content": "-----BEGIN CERTIFICATE-----\nwat\n-----END CERTIFICATE-----"
  },
  {
    "path": "testdata/cert.key",
    "content": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJh67FWpz8wrN1mM/\nCebkZN0zF83691ZVD83XlbNLRUqhRANCAAScfyPxScqz+Z/yNtAID/FOORy9J6LM\nDUAJevGDvAZCMp/nh+Ps3nLrMoRlykcux3mq+N8HPlJ8R3eetB4S1tHY\n-----END PRIVATE KEY-----"
  },
  {
    "path": "testdata/cert.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIIBaDCCAQ2gAwIBAgICBNIwCgYIKoZIzj0EAwIwFTETMBEGA1UEChMKR2F0dXMg\ndGVzdDAgFw0yMzA0MjIxODUwMDVaGA8yMjk3MDIwNDE4NTAwNVowFTETMBEGA1UE\nChMKR2F0dXMgdGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJx/I/FJyrP5\nn/I20AgP8U45HL0noswNQAl68YO8BkIyn+eH4+zecusyhGXKRy7Hear43wc+UnxH\nd560HhLW0dijSzBJMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcD\nATAMBgNVHRMBAf8EAjAAMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDAKBggqhkjOPQQD\nAgNJADBGAiEA/SdthKOoNw3azSHuPid7XJsXYB8DisIC9LBwcb/QTMECIQCAB36Y\nOI15ao+J/RUz2sXdPXCAN8hlohi6OnmZmJB32g==\n-----END CERTIFICATE-----"
  },
  {
    "path": "watchdog/alerting.go",
    "content": "package watchdog\n\nimport (\n\t\"errors\"\n\t\"log\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/logr\"\n)\n\n// HandleAlerting takes care of alerts to resolve and alerts to trigger based on result success or failure\nfunc HandleAlerting(ep *endpoint.Endpoint, result *endpoint.Result, alertingConfig *alerting.Config) {\n\tif alertingConfig == nil {\n\t\treturn\n\t}\n\tif result.Success {\n\t\thandleAlertsToResolve(ep, result, alertingConfig)\n\t} else {\n\t\thandleAlertsToTrigger(ep, result, alertingConfig)\n\t}\n}\n\nfunc handleAlertsToTrigger(ep *endpoint.Endpoint, result *endpoint.Result, alertingConfig *alerting.Config) {\n\tep.NumberOfSuccessesInARow = 0\n\tep.NumberOfFailuresInARow++\n\t// Store the current LastReminderSent time so all alert providers use the same reference time for reminder checks\n\t// This is important in case there are multiple alerts: if the first one sends a reminder, it would update the value\n\t// of ep.LastReminderSent (since ep is a pointer), so the second one would never send a reminder, even if it was due.\n\t// By storing the value in a local variable, we ensure all alerts use the same reference\n\tlastReminderSent := ep.LastReminderSent\n\tfor _, endpointAlert := range ep.Alerts {\n\t\t// If the alert hasn't been triggered, move to the next one\n\t\tif !endpointAlert.IsEnabled() || endpointAlert.FailureThreshold > ep.NumberOfFailuresInARow {\n\t\t\tcontinue\n\t\t}\n\t\t// Determine if an initial alert should be sent\n\t\tsendInitialAlert := !endpointAlert.Triggered\n\t\t// Determine if a reminder should be sent\n\t\tsendReminder := endpointAlert.Triggered && endpointAlert.MinimumReminderInterval > 0 && time.Since(lastReminderSent) >= endpointAlert.MinimumReminderInterval\n\t\t// If neither initial alert nor reminder needs to be sent, skip to the next alert\n\t\tif !sendInitialAlert && !sendReminder {\n\t\t\tlogr.Debugf(\"[watchdog.handleAlertsToTrigger] Alert for endpoint=%s with description='%s' is not due for triggering or reminding, skipping\", ep.Name, endpointAlert.GetDescription())\n\t\t\tcontinue\n\t\t}\n\t\talertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type)\n\t\tif alertProvider != nil {\n\t\t\tlogr.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())\n\t\t\tvar err error\n\t\t\talertType := \"reminder\"\n\t\t\tif sendInitialAlert {\n\t\t\t\talertType = \"initial\"\n\t\t\t}\n\t\t\tlog.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())\n\t\t\tif os.Getenv(\"MOCK_ALERT_PROVIDER\") == \"true\" {\n\t\t\t\tif os.Getenv(\"MOCK_ALERT_PROVIDER_ERROR\") == \"true\" {\n\t\t\t\t\terr = errors.New(\"error\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\terr = alertProvider.Send(ep, endpointAlert, result, false)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[watchdog.handleAlertsToTrigger] Failed to send an alert for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t} else {\n\t\t\t\t// Mark initial alert as triggered and update last reminder time\n\t\t\t\tif sendInitialAlert {\n\t\t\t\t\tendpointAlert.Triggered = true\n\t\t\t\t}\n\t\t\t\tep.LastReminderSent = time.Now()\n\t\t\t\tif err := store.Get().UpsertTriggeredEndpointAlert(ep, endpointAlert); err != nil {\n\t\t\t\t\tlogr.Errorf(\"[watchdog.handleAlertsToTrigger] Failed to persist triggered endpoint alert for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tlogr.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())\n\t\t}\n\t}\n}\n\nfunc handleAlertsToResolve(ep *endpoint.Endpoint, result *endpoint.Result, alertingConfig *alerting.Config) {\n\tep.NumberOfSuccessesInARow++\n\tfor _, endpointAlert := range ep.Alerts {\n\t\tisStillBelowSuccessThreshold := endpointAlert.SuccessThreshold > ep.NumberOfSuccessesInARow\n\t\tif isStillBelowSuccessThreshold && endpointAlert.IsEnabled() && endpointAlert.Triggered {\n\t\t\t// Persist NumberOfSuccessesInARow\n\t\t\tif err := store.Get().UpsertTriggeredEndpointAlert(ep, endpointAlert); err != nil {\n\t\t\t\tlogr.Errorf(\"[watchdog.handleAlertsToResolve] Failed to update triggered endpoint alert for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t}\n\t\t}\n\t\tif !endpointAlert.IsEnabled() || !endpointAlert.Triggered || isStillBelowSuccessThreshold {\n\t\t\tcontinue\n\t\t}\n\t\t// Even if the alert provider returns an error, we still set the alert's Triggered variable to false.\n\t\t// Further explanation can be found on Alert's Triggered field.\n\t\tendpointAlert.Triggered = false\n\t\tif err := store.Get().DeleteTriggeredEndpointAlert(ep, endpointAlert); err != nil {\n\t\t\tlogr.Errorf(\"[watchdog.handleAlertsToResolve] Failed to delete persisted triggered endpoint alert for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t}\n\t\tif !endpointAlert.IsSendingOnResolved() {\n\t\t\tlogr.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())\n\t\t\tcontinue\n\t\t}\n\t\talertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type)\n\t\tif alertProvider != nil {\n\t\t\tlogr.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())\n\t\t\terr := alertProvider.Send(ep, endpointAlert, result, true)\n\t\t\tif err != nil {\n\t\t\t\tlogr.Errorf(\"[watchdog.handleAlertsToResolve] Failed to send an alert for endpoint with key=%s: %s\", ep.Key(), err.Error())\n\t\t\t}\n\t\t} else {\n\t\t\tlogr.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())\n\t\t}\n\t}\n\tep.NumberOfFailuresInARow = 0\n}\n"
  },
  {
    "path": "watchdog/alerting_test.go",
    "content": "package watchdog\n\nimport (\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/alerting\"\n\t\"github.com/TwiN/gatus/v5/alerting/alert\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/clickup\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/custom\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/datadog\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/discord\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/email\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/ifttt\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/line\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/matrix\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/mattermost\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/messagebird\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/newrelic\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/pagerduty\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/plivo\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/pushover\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/signl4\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/slack\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/teams\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/telegram\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/twilio\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/vonage\"\n\t\"github.com/TwiN/gatus/v5/alerting/provider/zapier\"\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n)\n\nfunc TestHandleAlerting(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\n\tcfg := &config.Config{\n\t\tAlerting: &alerting.Config{\n\t\t\tCustom: &custom.AlertProvider{\n\t\t\t\tDefaultConfig: custom.Config{\n\t\t\t\t\tURL:    \"https://twin.sh/health\",\n\t\t\t\t\tMethod: \"GET\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tenabled := true\n\tep := &endpoint.Endpoint{\n\t\tURL: \"https://example.com\",\n\t\tAlerts: []*alert.Alert{\n\t\t\t{\n\t\t\t\tType:             alert.TypeCustom,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tFailureThreshold: 2,\n\t\t\t\tSuccessThreshold: 3,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tTriggered:        false,\n\t\t\t},\n\t\t},\n\t}\n\n\tverify(t, ep, 0, 0, false, \"The alert shouldn't start triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 1, 0, false, \"The alert shouldn't have triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 2, 0, true, \"The alert should've triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 3, 0, true, \"The alert should still be triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 4, 0, true, \"The alert should still be triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 1, true, \"The alert should still be triggered (because endpoint.Alerts[0].SuccessThreshold is 3)\")\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 2, true, \"The alert should still be triggered (because endpoint.Alerts[0].SuccessThreshold is 3)\")\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 3, false, \"The alert should've been resolved\")\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 4, false, \"The alert should no longer be triggered\")\n}\n\nfunc TestHandleAlertingWhenAlertingConfigIsNil(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\tHandleAlerting(nil, nil, nil)\n}\n\nfunc TestHandleAlertingWithBadAlertProvider(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\n\tenabled := true\n\tep := &endpoint.Endpoint{\n\t\tURL: \"http://example.com\",\n\t\tAlerts: []*alert.Alert{\n\t\t\t{\n\t\t\t\tType:             alert.TypeCustom,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tFailureThreshold: 1,\n\t\t\t\tSuccessThreshold: 1,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tTriggered:        false,\n\t\t\t},\n\t\t},\n\t}\n\n\tverify(t, ep, 0, 0, false, \"The alert shouldn't start triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, &alerting.Config{})\n\tverify(t, ep, 1, 0, false, \"The alert shouldn't have triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, &alerting.Config{})\n\tverify(t, ep, 2, 0, false, \"The alert shouldn't have triggered, because the provider wasn't configured properly\")\n}\n\nfunc TestHandleAlertingWhenTriggeredAlertIsAlmostResolvedButendpointStartFailingAgain(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\n\tcfg := &config.Config{\n\t\tAlerting: &alerting.Config{\n\t\t\tCustom: &custom.AlertProvider{\n\t\t\t\tDefaultConfig: custom.Config{\n\t\t\t\t\tURL:    \"https://twin.sh/health\",\n\t\t\t\t\tMethod: \"GET\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tenabled := true\n\tep := &endpoint.Endpoint{\n\t\tURL: \"https://example.com\",\n\t\tAlerts: []*alert.Alert{\n\t\t\t{\n\t\t\t\tType:             alert.TypeCustom,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tFailureThreshold: 2,\n\t\t\t\tSuccessThreshold: 3,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tTriggered:        true,\n\t\t\t},\n\t\t},\n\t\tNumberOfFailuresInARow: 1,\n\t}\n\n\t// This test simulate an alert that was already triggered\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 2, 0, true, \"The alert was already triggered at the beginning of this test\")\n}\n\nfunc TestHandleAlertingWhenTriggeredAlertIsResolvedButSendOnResolvedIsFalse(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\n\tcfg := &config.Config{\n\t\tAlerting: &alerting.Config{\n\t\t\tCustom: &custom.AlertProvider{\n\t\t\t\tDefaultConfig: custom.Config{\n\t\t\t\t\tURL:    \"https://twin.sh/health\",\n\t\t\t\t\tMethod: \"GET\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tenabled := true\n\tdisabled := false\n\tep := &endpoint.Endpoint{\n\t\tURL: \"https://example.com\",\n\t\tAlerts: []*alert.Alert{\n\t\t\t{\n\t\t\t\tType:             alert.TypeCustom,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tFailureThreshold: 1,\n\t\t\t\tSuccessThreshold: 1,\n\t\t\t\tSendOnResolved:   &disabled,\n\t\t\t\tTriggered:        true,\n\t\t\t},\n\t\t},\n\t\tNumberOfFailuresInARow: 1,\n\t}\n\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 1, false, \"The alert should've been resolved\")\n}\n\nfunc TestHandleAlertingWhenTriggeredAlertIsResolvedPagerDuty(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\n\tcfg := &config.Config{\n\t\tAlerting: &alerting.Config{\n\t\t\tPagerDuty: &pagerduty.AlertProvider{\n\t\t\t\tDefaultConfig: pagerduty.Config{\n\t\t\t\t\tIntegrationKey: \"00000000000000000000000000000000\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tenabled := true\n\tep := &endpoint.Endpoint{\n\t\tURL: \"https://example.com\",\n\t\tAlerts: []*alert.Alert{\n\t\t\t{\n\t\t\t\tType:             alert.TypePagerDuty,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tFailureThreshold: 1,\n\t\t\t\tSuccessThreshold: 1,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tTriggered:        false,\n\t\t\t},\n\t\t},\n\t\tNumberOfFailuresInARow: 0,\n\t}\n\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 1, 0, true, \"\")\n\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 1, false, \"The alert should've been resolved\")\n}\n\nfunc TestHandleAlertingWhenTriggeredAlertIsResolvedPushover(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\n\tcfg := &config.Config{\n\t\tAlerting: &alerting.Config{\n\t\t\tPushover: &pushover.AlertProvider{\n\t\t\t\tDefaultConfig: pushover.Config{\n\t\t\t\t\tApplicationToken: \"000000000000000000000000000000\",\n\t\t\t\t\tUserKey:          \"000000000000000000000000000000\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tenabled := true\n\tep := &endpoint.Endpoint{\n\t\tURL: \"https://example.com\",\n\t\tAlerts: []*alert.Alert{\n\t\t\t{\n\t\t\t\tType:             alert.TypePushover,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tFailureThreshold: 1,\n\t\t\t\tSuccessThreshold: 1,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tTriggered:        false,\n\t\t\t},\n\t\t},\n\t\tNumberOfFailuresInARow: 0,\n\t}\n\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 1, 0, true, \"\")\n\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 1, false, \"The alert should've been resolved\")\n}\n\nfunc TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\tenabled := true\n\tscenarios := []struct {\n\t\tName           string\n\t\tAlertingConfig *alerting.Config\n\t\tAlertType      alert.Type\n\t}{\n\t\t{\n\t\t\tName:      \"custom\",\n\t\t\tAlertType: alert.TypeCustom,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tCustom: &custom.AlertProvider{\n\t\t\t\t\tDefaultConfig: custom.Config{\n\t\t\t\t\t\tURL:    \"https://twin.sh/health\",\n\t\t\t\t\t\tMethod: \"GET\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"datadog\",\n\t\t\tAlertType: alert.TypeDatadog,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tDatadog: &datadog.AlertProvider{\n\t\t\t\t\tDefaultConfig: datadog.Config{\n\t\t\t\t\t\tAPIKey: \"test-key\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"discord\",\n\t\t\tAlertType: alert.TypeDiscord,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tDiscord: &discord.AlertProvider{\n\t\t\t\t\tDefaultConfig: discord.Config{\n\t\t\t\t\t\tWebhookURL: \"https://example.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"email\",\n\t\t\tAlertType: alert.TypeEmail,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tEmail: &email.AlertProvider{\n\t\t\t\t\tDefaultConfig: email.Config{\n\t\t\t\t\t\tFrom:     \"from@example.com\",\n\t\t\t\t\t\tPassword: \"hunter2\",\n\t\t\t\t\t\tHost:     \"mail.example.com\",\n\t\t\t\t\t\tPort:     587,\n\t\t\t\t\t\tTo:       \"to@example.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"ifttt\",\n\t\t\tAlertType: alert.TypeIFTTT,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tIFTTT: &ifttt.AlertProvider{\n\t\t\t\t\tDefaultConfig: ifttt.Config{\n\t\t\t\t\t\tWebhookKey: \"test-key\",\n\t\t\t\t\t\tEventName:  \"test-event\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"line\",\n\t\t\tAlertType: alert.TypeLine,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tLine: &line.AlertProvider{\n\t\t\t\t\tDefaultConfig: line.Config{\n\t\t\t\t\t\tChannelAccessToken: \"test-token\",\n\t\t\t\t\t\tUserIDs:            []string{\"test-user\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"mattermost\",\n\t\t\tAlertType: alert.TypeMattermost,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tMattermost: &mattermost.AlertProvider{\n\t\t\t\t\tDefaultConfig: mattermost.Config{\n\t\t\t\t\t\tWebhookURL: \"https://example.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"messagebird\",\n\t\t\tAlertType: alert.TypeMessagebird,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tMessagebird: &messagebird.AlertProvider{\n\t\t\t\t\tDefaultConfig: messagebird.Config{\n\t\t\t\t\t\tAccessKey:  \"1\",\n\t\t\t\t\t\tOriginator: \"2\",\n\t\t\t\t\t\tRecipients: \"3\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"newrelic\",\n\t\t\tAlertType: alert.TypeNewRelic,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tNewRelic: &newrelic.AlertProvider{\n\t\t\t\t\tDefaultConfig: newrelic.Config{\n\t\t\t\t\t\tInsertKey: \"test-key\",\n\t\t\t\t\t\tAccountID: \"test-account\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"pagerduty\",\n\t\t\tAlertType: alert.TypePagerDuty,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tPagerDuty: &pagerduty.AlertProvider{\n\t\t\t\t\tDefaultConfig: pagerduty.Config{\n\t\t\t\t\t\tIntegrationKey: \"00000000000000000000000000000000\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"plivo\",\n\t\t\tAlertType: alert.TypePlivo,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tPlivo: &plivo.AlertProvider{\n\t\t\t\t\tDefaultConfig: plivo.Config{\n\t\t\t\t\t\tAuthID:    \"test-id\",\n\t\t\t\t\t\tAuthToken: \"test-token\",\n\t\t\t\t\t\tFrom:      \"test-from\",\n\t\t\t\t\t\tTo:        []string{\"test-to\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"pushover\",\n\t\t\tAlertType: alert.TypePushover,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tPushover: &pushover.AlertProvider{\n\t\t\t\t\tDefaultConfig: pushover.Config{\n\t\t\t\t\t\tApplicationToken: \"000000000000000000000000000000\",\n\t\t\t\t\t\tUserKey:          \"000000000000000000000000000000\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"signl4\",\n\t\t\tAlertType: alert.TypeSIGNL4,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tSIGNL4: &signl4.AlertProvider{\n\t\t\t\t\tDefaultConfig: signl4.Config{\n\t\t\t\t\t\tTeamSecret: \"test-secret\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"slack\",\n\t\t\tAlertType: alert.TypeSlack,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tSlack: &slack.AlertProvider{\n\t\t\t\t\tDefaultConfig: slack.Config{\n\t\t\t\t\t\tWebhookURL: \"https://example.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"teams\",\n\t\t\tAlertType: alert.TypeTeams,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tTeams: &teams.AlertProvider{\n\t\t\t\t\tDefaultConfig: teams.Config{\n\t\t\t\t\t\tWebhookURL: \"https://example.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"telegram\",\n\t\t\tAlertType: alert.TypeTelegram,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tTelegram: &telegram.AlertProvider{\n\t\t\t\t\tDefaultConfig: telegram.Config{\n\t\t\t\t\t\tToken: \"1\",\n\t\t\t\t\t\tID:    \"2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"twilio\",\n\t\t\tAlertType: alert.TypeTwilio,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tTwilio: &twilio.AlertProvider{\n\t\t\t\t\tDefaultConfig: twilio.Config{\n\t\t\t\t\t\tSID:   \"1\",\n\t\t\t\t\t\tToken: \"2\",\n\t\t\t\t\t\tFrom:  \"3\",\n\t\t\t\t\t\tTo:    \"4\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"vonage\",\n\t\t\tAlertType: alert.TypeVonage,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tVonage: &vonage.AlertProvider{\n\t\t\t\t\tDefaultConfig: vonage.Config{\n\t\t\t\t\t\tAPIKey:    \"test-key\",\n\t\t\t\t\t\tAPISecret: \"test-secret\",\n\t\t\t\t\t\tFrom:      \"test-from\",\n\t\t\t\t\t\tTo:        []string{\"test-to\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"zapier\",\n\t\t\tAlertType: alert.TypeZapier,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tZapier: &zapier.AlertProvider{\n\t\t\t\t\tDefaultConfig: zapier.Config{\n\t\t\t\t\t\tWebhookURL: \"https://example.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"matrix\",\n\t\t\tAlertType: alert.TypeMatrix,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tMatrix: &matrix.AlertProvider{\n\t\t\t\t\tDefaultConfig: matrix.Config{\n\t\t\t\t\t\tServerURL:      \"https://example.com\",\n\t\t\t\t\t\tAccessToken:    \"1\",\n\t\t\t\t\t\tInternalRoomID: \"!a:example.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:      \"clickup\",\n\t\t\tAlertType: alert.TypeClickUp,\n\t\t\tAlertingConfig: &alerting.Config{\n\t\t\t\tClickUp: &clickup.AlertProvider{\n\t\t\t\t\tDefaultConfig: clickup.Config{\n\t\t\t\t\t\tListID: \"test-list-id\",\n\t\t\t\t\t\tToken:  \"test-token\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tep := &endpoint.Endpoint{\n\t\t\t\tURL: \"https://example.com\",\n\t\t\t\tAlerts: []*alert.Alert{\n\t\t\t\t\t{\n\t\t\t\t\t\tType:             scenario.AlertType,\n\t\t\t\t\t\tEnabled:          &enabled,\n\t\t\t\t\t\tFailureThreshold: 2,\n\t\t\t\t\t\tSuccessThreshold: 2,\n\t\t\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\t\t\tTriggered:        false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER_ERROR\", \"true\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 1, 0, false, \"\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 2, 0, false, \"The alert should have failed to trigger, because the alert provider is returning an error\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 3, 0, false, \"The alert should still not be triggered, because the alert provider is still returning an error\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 4, 0, false, \"The alert should still not be triggered, because the alert provider is still returning an error\")\n\t\t\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER_ERROR\", \"false\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 5, 0, true, \"The alert should've been triggered because the alert provider is no longer returning an error\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 0, 1, true, \"The alert should've still been triggered\")\n\t\t\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER_ERROR\", \"true\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 0, 2, false, \"The alert should've been resolved DESPITE THE ALERT PROVIDER RETURNING AN ERROR. See Alert.Triggered for further explanation.\")\n\t\t\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER_ERROR\", \"false\")\n\n\t\t\t// Make sure that everything's working as expected after a rough patch\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 1, 0, false, \"\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 2, 0, true, \"The alert should have triggered\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 0, 1, true, \"The alert should still be triggered\")\n\t\t\tHandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig)\n\t\t\tverify(t, ep, 0, 2, false, \"The alert should have been resolved\")\n\t\t})\n\t}\n\n}\n\nfunc TestHandleAlertingWithProviderThatOnlyReturnsErrorOnResolve(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\n\tcfg := &config.Config{\n\t\tAlerting: &alerting.Config{\n\t\t\tCustom: &custom.AlertProvider{\n\t\t\t\tDefaultConfig: custom.Config{\n\t\t\t\t\tURL:    \"https://twin.sh/health\",\n\t\t\t\t\tMethod: \"GET\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tenabled := true\n\tep := &endpoint.Endpoint{\n\t\tURL: \"https://example.com\",\n\t\tAlerts: []*alert.Alert{\n\t\t\t{\n\t\t\t\tType:             alert.TypeCustom,\n\t\t\t\tEnabled:          &enabled,\n\t\t\t\tFailureThreshold: 1,\n\t\t\t\tSuccessThreshold: 1,\n\t\t\t\tSendOnResolved:   &enabled,\n\t\t\t\tTriggered:        false,\n\t\t\t},\n\t\t},\n\t}\n\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 1, 0, true, \"\")\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER_ERROR\", \"true\")\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 1, false, \"\")\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER_ERROR\", \"false\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 1, 0, true, \"\")\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER_ERROR\", \"true\")\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 1, false, \"\")\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER_ERROR\", \"false\")\n\n\t// Make sure that everything's working as expected after a rough patch\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 1, 0, true, \"\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 2, 0, true, \"\")\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 1, false, \"\")\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n\tverify(t, ep, 0, 2, false, \"\")\n}\n\nfunc TestHandleAlertingWithMinimumReminderInterval(t *testing.T) {\n\t_ = os.Setenv(\"MOCK_ALERT_PROVIDER\", \"true\")\n\tdefer os.Clearenv()\n\n\tcfg := &config.Config{\n\t\tAlerting: &alerting.Config{\n\t\t\tCustom: &custom.AlertProvider{\n\t\t\t\tDefaultConfig: custom.Config{\n\t\t\t\t\tURL:    \"https://twin.sh/health\",\n\t\t\t\t\tMethod: \"GET\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tenabled := true\n\tep := &endpoint.Endpoint{\n\t\tURL: \"https://example.com\",\n\t\tAlerts: []*alert.Alert{\n\t\t\t{\n\t\t\t\tType:                    alert.TypeCustom,\n\t\t\t\tEnabled:                 &enabled,\n\t\t\t\tFailureThreshold:        2,\n\t\t\t\tSuccessThreshold:        3,\n\t\t\t\tSendOnResolved:          &enabled,\n\t\t\t\tTriggered:               false,\n\t\t\t\tMinimumReminderInterval: 5 * time.Minute,\n\t\t\t},\n\t\t},\n\t}\n\n\tverify(t, ep, 0, 0, false, \"The alert shouldn't start triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 1, 0, false, \"The alert shouldn't have triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 2, 0, true, \"The alert should've triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 3, 0, true, \"The alert should still be triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting)\n\tverify(t, ep, 4, 0, true, \"The alert should still be triggered\")\n\tHandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting)\n}\n\nfunc verify(t *testing.T, ep *endpoint.Endpoint, expectedNumberOfFailuresInARow, expectedNumberOfSuccessInARow int, expectedTriggered bool, expectedTriggeredReason string) {\n\tif ep.NumberOfFailuresInARow != expectedNumberOfFailuresInARow {\n\t\tt.Errorf(\"endpoint.NumberOfFailuresInARow should've been %d, got %d\", expectedNumberOfFailuresInARow, ep.NumberOfFailuresInARow)\n\t}\n\tif ep.NumberOfSuccessesInARow != expectedNumberOfSuccessInARow {\n\t\tt.Errorf(\"endpoint.NumberOfSuccessesInARow should've been %d, got %d\", expectedNumberOfSuccessInARow, ep.NumberOfSuccessesInARow)\n\t}\n\tif ep.Alerts[0].Triggered != expectedTriggered {\n\t\tif len(expectedTriggeredReason) != 0 {\n\t\t\tt.Error(expectedTriggeredReason)\n\t\t} else {\n\t\t\tif expectedTriggered {\n\t\t\t\tt.Error(\"The alert should've been triggered\")\n\t\t\t} else {\n\t\t\t\tt.Error(\"The alert shouldn't have been triggered\")\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "watchdog/endpoint.go",
    "content": "package watchdog\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/metrics\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/logr\"\n)\n\n// monitorEndpoint a single endpoint in a loop\nfunc monitorEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []string, ctx context.Context) {\n\t// Run it immediately on start\n\texecuteEndpoint(ep, cfg, extraLabels)\n\t// Loop for the next executions\n\tticker := time.NewTicker(ep.Interval)\n\tdefer ticker.Stop()\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tlogr.Warnf(\"[watchdog.monitorEndpoint] Canceling current execution of group=%s; endpoint=%s; key=%s\", ep.Group, ep.Name, ep.Key())\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\texecuteEndpoint(ep, cfg, extraLabels)\n\t\t}\n\t}\n\t// Just in case somebody wandered all the way to here and wonders, \"what about ExternalEndpoints?\"\n\t// Alerting is checked every time an external endpoint is pushed to Gatus, so they're not monitored\n\t// periodically like they are for normal endpoints.\n}\n\nfunc executeEndpoint(ep *endpoint.Endpoint, cfg *config.Config, extraLabels []string) {\n\t// Acquire semaphore to limit concurrent endpoint monitoring\n\tif err := monitoringSemaphore.Acquire(ctx, 1); err != nil {\n\t\t// Only fails if context is cancelled (during shutdown)\n\t\tlogr.Debugf(\"[watchdog.executeEndpoint] Context cancelled, skipping execution: %s\", err.Error())\n\t\treturn\n\t}\n\tdefer monitoringSemaphore.Release(1)\n\t// If there's a connectivity checker configured, check if Gatus has internet connectivity\n\tif cfg.Connectivity != nil && cfg.Connectivity.Checker != nil && !cfg.Connectivity.Checker.IsConnected() {\n\t\tlogr.Infof(\"[watchdog.executeEndpoint] No connectivity; skipping execution\")\n\t\treturn\n\t}\n\tlogr.Debugf(\"[watchdog.executeEndpoint] Monitoring group=%s; endpoint=%s; key=%s\", ep.Group, ep.Name, ep.Key())\n\tresult := ep.EvaluateHealth()\n\tif cfg.Metrics {\n\t\tmetrics.PublishMetricsForEndpoint(ep, result, extraLabels)\n\t}\n\tUpdateEndpointStatus(ep, result)\n\tif logr.GetThreshold() == logr.LevelDebug && !result.Success {\n\t\tlogr.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)\n\t} else {\n\t\tlogr.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))\n\t}\n\tinEndpointMaintenanceWindow := false\n\tfor _, maintenanceWindow := range ep.MaintenanceWindows {\n\t\tif maintenanceWindow.IsUnderMaintenance() {\n\t\t\tlogr.Debug(\"[watchdog.executeEndpoint] Under endpoint maintenance window\")\n\t\t\tinEndpointMaintenanceWindow = true\n\t\t}\n\t}\n\tif !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {\n\t\tHandleAlerting(ep, result, cfg.Alerting)\n\t} else {\n\t\tlogr.Debug(\"[watchdog.executeEndpoint] Not handling alerting because currently in the maintenance window\")\n\t}\n\tlogr.Debugf(\"[watchdog.executeEndpoint] Waiting for interval=%s before monitoring group=%s endpoint=%s (key=%s) again\", ep.Interval, ep.Group, ep.Name, ep.Key())\n}\n\n// UpdateEndpointStatus persists the endpoint result in the storage\nfunc UpdateEndpointStatus(ep *endpoint.Endpoint, result *endpoint.Result) {\n\tif err := store.Get().InsertEndpointResult(ep, result); err != nil {\n\t\tlogr.Errorf(\"[watchdog.UpdateEndpointStatus] Failed to insert result in storage: %s\", err.Error())\n\t}\n}\n"
  },
  {
    "path": "watchdog/external_endpoint.go",
    "content": "package watchdog\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/endpoint\"\n\t\"github.com/TwiN/gatus/v5/metrics\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/logr\"\n)\n\nfunc monitorExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, cfg *config.Config, extraLabels []string, ctx context.Context) {\n\tticker := time.NewTicker(ee.Heartbeat.Interval)\n\tdefer ticker.Stop()\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tlogr.Warnf(\"[watchdog.monitorExternalEndpointHeartbeat] Canceling current execution of group=%s; endpoint=%s; key=%s\", ee.Group, ee.Name, ee.Key())\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\texecuteExternalEndpointHeartbeat(ee, cfg, extraLabels)\n\t\t}\n\t}\n}\n\nfunc executeExternalEndpointHeartbeat(ee *endpoint.ExternalEndpoint, cfg *config.Config, extraLabels []string) {\n\t// Acquire semaphore to limit concurrent external endpoint monitoring\n\tif err := monitoringSemaphore.Acquire(ctx, 1); err != nil {\n\t\t// Only fails if context is cancelled (during shutdown)\n\t\tlogr.Debugf(\"[watchdog.executeExternalEndpointHeartbeat] Context cancelled, skipping execution: %s\", err.Error())\n\t\treturn\n\t}\n\tdefer monitoringSemaphore.Release(1)\n\t// If there's a connectivity checker configured, check if Gatus has internet connectivity\n\tif cfg.Connectivity != nil && cfg.Connectivity.Checker != nil && !cfg.Connectivity.Checker.IsConnected() {\n\t\tlogr.Infof(\"[watchdog.monitorExternalEndpointHeartbeat] No connectivity; skipping execution\")\n\t\treturn\n\t}\n\tlogr.Debugf(\"[watchdog.monitorExternalEndpointHeartbeat] Checking heartbeat for group=%s; endpoint=%s; key=%s\", ee.Group, ee.Name, ee.Key())\n\tconvertedEndpoint := ee.ToEndpoint()\n\thasReceivedResultWithinHeartbeatInterval, err := store.Get().HasEndpointStatusNewerThan(ee.Key(), time.Now().Add(-ee.Heartbeat.Interval))\n\tif err != nil {\n\t\tlogr.Errorf(\"[watchdog.monitorExternalEndpointHeartbeat] Failed to check if endpoint has received a result within the heartbeat interval: %s\", err.Error())\n\t\treturn\n\t}\n\tif hasReceivedResultWithinHeartbeatInterval {\n\t\t// If we received a result within the heartbeat interval, we don't want to create a successful result, so we\n\t\t// skip the rest. We don't have to worry about alerting or metrics, because if the previous heartbeat failed\n\t\t// while this one succeeds, it implies that there was a new result pushed, and that result being pushed\n\t\t// should've resolved the alert.\n\t\tlogr.Infof(\"[watchdog.monitorExternalEndpointHeartbeat] Checked heartbeat for group=%s; endpoint=%s; key=%s; success=%v; errors=%d\", ee.Group, ee.Name, ee.Key(), hasReceivedResultWithinHeartbeatInterval, 0)\n\t\treturn\n\t}\n\t// All code after this point assumes the heartbeat failed\n\tresult := &endpoint.Result{\n\t\tTimestamp: time.Now(),\n\t\tSuccess:   false,\n\t\tErrors:    []string{\"heartbeat: no update received within \" + ee.Heartbeat.Interval.String()},\n\t}\n\tif cfg.Metrics {\n\t\tmetrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)\n\t}\n\tUpdateEndpointStatus(convertedEndpoint, result)\n\tlogr.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))\n\tinEndpointMaintenanceWindow := false\n\tfor _, maintenanceWindow := range ee.MaintenanceWindows {\n\t\tif maintenanceWindow.IsUnderMaintenance() {\n\t\t\tlogr.Debug(\"[watchdog.monitorExternalEndpointHeartbeat] Under endpoint maintenance window\")\n\t\t\tinEndpointMaintenanceWindow = true\n\t\t}\n\t}\n\tif !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {\n\t\tHandleAlerting(convertedEndpoint, result, cfg.Alerting)\n\t\t// Sync the failure/success counters back to the external endpoint\n\t\tee.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow\n\t\tee.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow\n\t} else {\n\t\tlogr.Debug(\"[watchdog.monitorExternalEndpointHeartbeat] Not handling alerting because currently in the maintenance window\")\n\t}\n\tlogr.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())\n}\n"
  },
  {
    "path": "watchdog/suite.go",
    "content": "package watchdog\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"github.com/TwiN/gatus/v5/config/suite\"\n\t\"github.com/TwiN/gatus/v5/metrics\"\n\t\"github.com/TwiN/gatus/v5/storage/store\"\n\t\"github.com/TwiN/logr\"\n)\n\n// monitorSuite monitors a suite by executing it at regular intervals\nfunc monitorSuite(s *suite.Suite, cfg *config.Config, extraLabels []string, ctx context.Context) {\n\t// Execute immediately on start\n\texecuteSuite(s, cfg, extraLabels)\n\t// Set up ticker for periodic execution\n\tticker := time.NewTicker(s.Interval)\n\tdefer ticker.Stop()\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tlogr.Warnf(\"[watchdog.monitorSuite] Canceling monitoring for suite=%s\", s.Name)\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\texecuteSuite(s, cfg, extraLabels)\n\t\t}\n\t}\n}\n\n// executeSuite executes a suite with proper concurrency control\nfunc executeSuite(s *suite.Suite, cfg *config.Config, extraLabels []string) {\n\t// Acquire semaphore to limit concurrent suite monitoring\n\tif err := monitoringSemaphore.Acquire(ctx, 1); err != nil {\n\t\t// Only fails if context is cancelled (during shutdown)\n\t\tlogr.Debugf(\"[watchdog.executeSuite] Context cancelled, skipping execution: %s\", err.Error())\n\t\treturn\n\t}\n\tdefer monitoringSemaphore.Release(1)\n\t// Check connectivity if configured\n\tif cfg.Connectivity != nil && cfg.Connectivity.Checker != nil && !cfg.Connectivity.Checker.IsConnected() {\n\t\tlogr.Infof(\"[watchdog.executeSuite] No connectivity; skipping suite=%s\", s.Name)\n\t\treturn\n\t}\n\tlogr.Debugf(\"[watchdog.executeSuite] Monitoring group=%s; suite=%s; key=%s\", s.Group, s.Name, s.Key())\n\t// Execute the suite using its Execute method\n\tresult := s.Execute()\n\t// Publish metrics for the suite execution\n\tif cfg.Metrics {\n\t\tmetrics.PublishMetricsForSuite(s, result, extraLabels)\n\t}\n\t// Store result\n\tUpdateSuiteStatus(s, result)\n\t// Handle alerting for suite endpoints\n\tfor i, ep := range s.Endpoints {\n\t\tif i < len(result.EndpointResults) {\n\t\t\tepResult := result.EndpointResults[i]\n\t\t\t// Handle alerting if configured and not under maintenance\n\t\t\tif cfg.Alerting != nil && !cfg.Maintenance.IsUnderMaintenance() {\n\t\t\t\t// Check if endpoint is under maintenance\n\t\t\t\tinEndpointMaintenanceWindow := false\n\t\t\t\tfor _, maintenanceWindow := range ep.MaintenanceWindows {\n\t\t\t\t\tif maintenanceWindow.IsUnderMaintenance() {\n\t\t\t\t\t\tlogr.Debug(\"[watchdog.executeSuite] Endpoint under maintenance window\")\n\t\t\t\t\t\tinEndpointMaintenanceWindow = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !inEndpointMaintenanceWindow {\n\t\t\t\t\tHandleAlerting(ep, epResult, cfg.Alerting)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tlogr.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))\n}\n\n// UpdateSuiteStatus persists the suite result in the database\nfunc UpdateSuiteStatus(s *suite.Suite, result *suite.Result) {\n\tif err := store.Get().InsertSuiteResult(s, result); err != nil {\n\t\tlogr.Errorf(\"[watchdog.executeSuite] Failed to insert suite result for suite=%s: %v\", s.Name, err)\n\t}\n}\n"
  },
  {
    "path": "watchdog/watchdog.go",
    "content": "package watchdog\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/TwiN/gatus/v5/config\"\n\t\"golang.org/x/sync/semaphore\"\n)\n\nconst (\n\t// UnlimitedConcurrencyWeight is the semaphore weight used when concurrency is set to 0 (unlimited).\n\t// This provides a practical upper limit while allowing very high concurrency for large deployments.\n\tUnlimitedConcurrencyWeight = 10000\n)\n\nvar (\n\t// monitoringSemaphore is used to limit the number of endpoints/suites that can be evaluated concurrently.\n\t// Without this, conditions using response time may become inaccurate.\n\tmonitoringSemaphore *semaphore.Weighted\n\n\tctx        context.Context\n\tcancelFunc context.CancelFunc\n)\n\n// Monitor loops over each endpoint and starts a goroutine to monitor each endpoint separately\nfunc Monitor(cfg *config.Config) {\n\tctx, cancelFunc = context.WithCancel(context.Background())\n\t// Initialize semaphore based on concurrency configuration\n\tif cfg.Concurrency == 0 {\n\t\t// Unlimited concurrency - use a very high limit\n\t\tmonitoringSemaphore = semaphore.NewWeighted(UnlimitedConcurrencyWeight)\n\t} else {\n\t\t// Limited concurrency based on configuration\n\t\tmonitoringSemaphore = semaphore.NewWeighted(int64(cfg.Concurrency))\n\t}\n\textraLabels := cfg.GetUniqueExtraMetricLabels()\n\tfor _, endpoint := range cfg.Endpoints {\n\t\tif endpoint.IsEnabled() {\n\t\t\t// To prevent multiple requests from running at the same time, we'll wait for a little before each iteration\n\t\t\ttime.Sleep(222 * time.Millisecond)\n\t\t\tgo monitorEndpoint(endpoint, cfg, extraLabels, ctx)\n\t\t}\n\t}\n\tfor _, externalEndpoint := range cfg.ExternalEndpoints {\n\t\t// Check if the external endpoint is enabled and is using heartbeat\n\t\t// If the external endpoint does not use heartbeat, then it does not need to be monitored periodically, because\n\t\t// alerting is checked every time an external endpoint is pushed to Gatus, unlike normal endpoints.\n\t\tif externalEndpoint.IsEnabled() && externalEndpoint.Heartbeat.Interval > 0 {\n\t\t\tgo monitorExternalEndpointHeartbeat(externalEndpoint, cfg, extraLabels, ctx)\n\t\t}\n\t}\n\tfor _, suite := range cfg.Suites {\n\t\tif suite.IsEnabled() {\n\t\t\ttime.Sleep(222 * time.Millisecond)\n\t\t\tgo monitorSuite(suite, cfg, extraLabels, ctx)\n\t\t}\n\t}\n}\n\n// Shutdown stops monitoring all endpoints\nfunc Shutdown(cfg *config.Config) {\n\t// Stop in-flight HTTP connections\n\tfor _, ep := range cfg.Endpoints {\n\t\tep.Close()\n\t}\n\tfor _, s := range cfg.Suites {\n\t\tfor _, ep := range s.Endpoints {\n\t\t\tep.Close()\n\t\t}\n\t}\n\tcancelFunc()\n}\n"
  },
  {
    "path": "web/app/.gitignore",
    "content": ".DS_Store\nnode_modules\n/dist\n\n\n# local env files\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\n\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "web/app/README.md",
    "content": "# app\n\n## Project setup\n```\nnpm install\n```\n\n### Compiles and hot-reloads for development\n```\nnpm run serve\n```\n\n### Compiles and minifies for production\n```\nnpm run build\n```\n\n### Lints and fixes files\n```\nnpm run lint\n```\n\n### Customize configuration\nSee [Configuration Reference](https://cli.vuejs.org/config/).\n"
  },
  {
    "path": "web/app/babel.config.js",
    "content": "module.exports = {\n  presets: [\n    '@vue/cli-plugin-babel/preset'\n  ]\n}\n"
  },
  {
    "path": "web/app/package.json",
    "content": "{\n  \"name\": \"gatus\",\n  \"version\": \"4.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"serve\": \"vue-cli-service serve --mode development\",\n    \"build\": \"vue-cli-service build --modern --mode production\",\n    \"lint\": \"vue-cli-service lint\"\n  },\n  \"dependencies\": {\n    \"chart.js\": \"^4.5.1\",\n    \"chartjs-adapter-date-fns\": \"^3.0.0\",\n    \"chartjs-plugin-annotation\": \"^3.1.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"core-js\": \"^3.45.0\",\n    \"date-fns\": \"^4.1.0\",\n    \"dompurify\": \"^3.3.0\",\n    \"lucide-vue-next\": \"^0.539.0\",\n    \"marked\": \"^16.4.1\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"vue\": \"^3.5.18\",\n    \"vue-chartjs\": \"^5.3.2\",\n    \"vue-router\": \"^4.5.1\"\n  },\n  \"devDependencies\": {\n    \"@babel/eslint-parser\": \"^7.25.1\",\n    \"@vue/cli-plugin-babel\": \"^5.0.8\",\n    \"@vue/cli-plugin-eslint\": \"^5.0.8\",\n    \"@vue/cli-plugin-router\": \"^5.0.8\",\n    \"@vue/cli-service\": \"^5.0.8\",\n    \"@vue/compiler-sfc\": \"^3.5.18\",\n    \"autoprefixer\": \"^10.4.21\",\n    \"eslint\": \"^8.57.1\",\n    \"eslint-plugin-vue\": \"^9.28.0\",\n    \"postcss\": \"^8.5.6\",\n    \"tailwindcss\": \"^3.1.8\"\n  },\n  \"eslintConfig\": {\n    \"root\": true,\n    \"env\": {\n      \"node\": true\n    },\n    \"extends\": [\n      \"plugin:vue/vue3-essential\",\n      \"eslint:recommended\"\n    ],\n    \"parserOptions\": {\n      \"parser\": \"@babel/eslint-parser\",\n      \"requireConfigFile\": false\n    },\n    \"rules\": {\n      \"vue/multi-word-component-names\": [\"error\", {\n        \"ignores\": [\"Home\", \"Details\", \"Loading\", \"Settings\", \"Social\", \"Tooltip\", \"Pagination\", \"Button\", \"Badge\", \"Card\", \"Input\", \"Select\"]\n      }]\n    },\n    \"globals\": {\n      \"defineProps\": \"readonly\",\n      \"defineEmits\": \"readonly\",\n      \"defineExpose\": \"readonly\",\n      \"withDefaults\": \"readonly\"\n    }\n  },\n  \"browserslist\": [\n    \"defaults\",\n    \"> 1%\",\n    \"last 2 versions\",\n    \"not dead\"\n  ]\n}\n"
  },
  {
    "path": "web/app/postcss.config.js",
    "content": "const tailwindcss = require('tailwindcss');\n\nmodule.exports = {\n\tplugins: [\n\t\ttailwindcss('./tailwind.config.js'),\n\t\trequire('autoprefixer'),\n\t],\n};"
  },
  {
    "path": "web/app/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"{{ .Theme }}\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <script type=\"text/javascript\">\n      window.config = {logo: \"{{ .UI.Logo }}\", header: \"{{ .UI.Header }}\", dashboardHeading: \"{{ .UI.DashboardHeading }}\", dashboardSubheading: \"{{ .UI.DashboardSubheading }}\", link: \"{{ .UI.Link }}\", buttons: [], maximumNumberOfResults: \"{{ .UI.MaximumNumberOfResults }}\", defaultSortBy: \"{{ .UI.DefaultSortBy }}\", defaultFilterBy: \"{{ .UI.DefaultFilterBy }}\"};{{- range .UI.Buttons}}window.config.buttons.push({name:\"{{ .Name }}\",link:\"{{ .Link }}\"});{{end}}\n      // Initialize theme immediately to prevent flash\n      (function() {\n        const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];\n        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n        if (themeFromCookie === 'dark' || (!themeFromCookie && prefersDark)) {\n          document.documentElement.classList.add('dark');\n        } else {\n          document.documentElement.classList.remove('dark');\n        }\n      })();\n    </script>\n    <title>{{ .UI.Title }}</title>\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\" />\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\" />\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"{{ .UI.Favicon.Size32x32 }}\" />\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"{{ .UI.Favicon.Size16x16 }}\" />\n    <link rel=\"manifest\" href=\"/manifest.json\" crossorigin=\"use-credentials\" />\n    <link rel=\"shortcut icon\" href=\"{{ .UI.Favicon.Default }}\" />\n    <link rel=\"stylesheet\" href=\"/css/custom.css\" />\n    <meta name=\"description\" content=\"{{ .UI.Description }}\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n    <meta name=\"apple-mobile-web-app-title\" content=\"{{ .UI.Title }}\" />\n    <meta name=\"application-name\" content=\"{{ .UI.Title }}\" />\n    <meta name=\"theme-color\" content=\"#f7f9fb\" />\n  </head>\n  <body>\n    <noscript><strong>Enable JavaScript to view this page.</strong></noscript>\n    <div id=\"app\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "web/app/public/manifest.json",
    "content": "{\n  \"id\": \"gatus\",\n  \"name\": \"Gatus\",\n  \"short_name\": \"Gatus\",\n  \"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\",\n  \"lang\": \"en\",\n  \"scope\": \"/\",\n  \"start_url\": \"/\",\n  \"theme_color\": \"#f7f9fb\",\n  \"background_color\": \"#f7f9fb\",\n  \"display\": \"standalone\",\n  \"icons\": [\n    {\n      \"src\": \"/logo-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/logo-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    }\n  ]\n}\n"
  },
  {
    "path": "web/app/src/App.vue",
    "content": "<template>\n  <div id=\"global\" class=\"bg-background text-foreground\">\n    <!-- Loading State -->\n    <div v-if=\"!retrievedConfig\" class=\"flex items-center justify-center min-h-screen\">\n      <Loading size=\"lg\" />\n    </div>\n\n    <!-- Main App Container -->\n    <div v-else-if=\"!config || !config.oidc || config.authenticated\" class=\"relative\">\n      <!-- Header -->\n      <header class=\"border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/60\">\n        <div class=\"container mx-auto px-4 py-4 max-w-7xl\">\n          <div class=\"flex items-center justify-between\">\n            <!-- Logo and Title -->\n            <div class=\"flex items-center gap-4\">\n              <component \n                :is=\"link ? 'a' : 'div'\" \n                :href=\"link\" \n                target=\"_blank\"\n                :class=\"['flex items-center gap-3', link && 'hover:opacity-80 transition-opacity']\"\n              >\n                <div class=\"w-12 h-12 flex items-center justify-center\">\n                  <img \n                    v-if=\"logo\" \n                    :src=\"logo\" \n                    alt=\"Gatus\" \n                    class=\"w-full h-full object-contain\"\n                  />\n                  <img \n                    v-else \n                    src=\"./assets/logo.svg\" \n                    alt=\"Gatus\" \n                    class=\"w-full h-full object-contain\"\n                  />\n                </div>\n                <div>\n                  <h1 class=\"text-2xl font-bold tracking-tight\">{{ header }}</h1>\n                  <p v-if=\"buttons && buttons.length\" class=\"text-sm text-muted-foreground\">\n                    System Monitoring Dashboard\n                  </p>\n                </div>\n              </component>\n            </div>\n\n            <!-- Right Side Actions -->\n            <div class=\"flex items-center gap-2\">\n              <!-- Navigation Links (Desktop) -->\n              <nav v-if=\"buttons && buttons.length\" class=\"hidden md:flex items-center gap-1\">\n                <a \n                  v-for=\"button in buttons\" \n                  :key=\"button.name\" \n                  :href=\"button.link\" \n                  target=\"_blank\"\n                  class=\"px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors\"\n                >\n                  {{ button.name }}\n                </a>\n              </nav>\n\n              <!-- Mobile Menu Button -->\n              <Button \n                v-if=\"buttons && buttons.length\" \n                variant=\"ghost\" \n                size=\"icon\" \n                class=\"md:hidden\"\n                @click=\"mobileMenuOpen = !mobileMenuOpen\"\n              >\n                <Menu v-if=\"!mobileMenuOpen\" class=\"h-5 w-5\" />\n                <X v-else class=\"h-5 w-5\" />\n              </Button>\n            </div>\n          </div>\n\n          <!-- Mobile Navigation -->\n          <nav \n            v-if=\"buttons && buttons.length && mobileMenuOpen\" \n            class=\"md:hidden mt-4 pt-4 border-t space-y-1\"\n          >\n            <a \n              v-for=\"button in buttons\" \n              :key=\"button.name\" \n              :href=\"button.link\" \n              target=\"_blank\"\n              class=\"block px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors\"\n              @click=\"mobileMenuOpen = false\"\n            >\n              {{ button.name }}\n            </a>\n          </nav>\n        </div>\n      </header>\n\n      <!-- Main Content -->\n      <main class=\"relative\">\n        <router-view @showTooltip=\"showTooltip\" :announcements=\"announcements\" />\n      </main>\n\n      <!-- Footer -->\n      <footer class=\"border-t mt-auto\">\n        <div class=\"container mx-auto px-4 py-6 max-w-7xl\">\n          <div class=\"flex flex-col items-center gap-4\">\n            <div class=\"text-sm text-muted-foreground text-center\">\n              Powered by <a href=\"https://gatus.io\" target=\"_blank\" class=\"font-medium text-emerald-800 hover:text-emerald-600\">Gatus</a>\n            </div>\n            <Social />\n          </div>\n        </div>\n      </footer>\n    </div>\n\n    <!-- OIDC Login Screen -->\n    <div v-else id=\"login-container\" class=\"flex items-center justify-center min-h-screen p-4\">\n      <Card class=\"w-full max-w-md\">\n        <CardHeader class=\"text-center\">\n          <img \n            src=\"./assets/logo.svg\" \n            alt=\"Gatus\" \n            class=\"w-20 h-20 mx-auto mb-4\"\n          />\n          <CardTitle class=\"text-3xl\">Gatus</CardTitle>\n          <p class=\"text-muted-foreground mt-2\">System Monitoring Dashboard</p>\n        </CardHeader>\n        <CardContent>\n          <div v-if=\"route && route.query.error\" class=\"mb-6\">\n            <div class=\"p-3 rounded-md bg-destructive/10 border border-destructive/20\">\n              <p class=\"text-sm text-destructive text-center\">\n                <span v-if=\"route.query.error === 'access_denied'\">\n                  You do not have access to this status page\n                </span>\n                <span v-else>{{ route.query.error }}</span>\n              </p>\n            </div>\n          </div>\n          \n          <a\n            :href=\"`/oidc/login`\"\n            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\"\n            @click=\"isOidcLoading = true\"\n          >\n            <Loading v-if=\"isOidcLoading\" size=\"xs\" />\n            <template v-else>\n              <LogIn class=\"mr-2 h-4 w-4\" />\n              Login with OIDC\n            </template>\n          </a>\n        </CardContent>\n      </Card>\n    </div>\n\n    <!-- Tooltip -->\n    <Tooltip :result=\"tooltip.result\" :event=\"tooltip.event\" :isPersistent=\"tooltipIsPersistent\" />\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onUnmounted } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { Menu, X, LogIn } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'\nimport Social from './components/Social.vue'\nimport Tooltip from './components/Tooltip.vue'\nimport Loading from './components/Loading.vue'\n\nconst route = useRoute()\n\n// State\nconst retrievedConfig = ref(false)\nconst config = ref({ oidc: false, authenticated: true })\nconst announcements = ref([])\nconst tooltip = ref({})\nconst mobileMenuOpen = ref(false)\nconst isOidcLoading = ref(false)\nconst tooltipIsPersistent = ref(false)\nlet configInterval = null\n\n// Computed properties\nconst logo = computed(() => {\n  return window.config && window.config.logo && window.config.logo !== '{{ .UI.Logo }}' ? window.config.logo : \"\"\n})\n\nconst header = computed(() => {\n  return window.config && window.config.header && window.config.header !== '{{ .UI.Header }}' ? window.config.header : \"Gatus\"\n})\n\nconst link = computed(() => {\n  return window.config && window.config.link && window.config.link !== '{{ .UI.Link }}' ? window.config.link : null\n})\n\nconst buttons = computed(() => {\n  return window.config && window.config.buttons ? window.config.buttons : []\n})\n\n// Methods\nconst fetchConfig = async () => {\n  try {\n    const response = await fetch(`/api/v1/config`, { credentials: 'include' })\n    if (response.status === 200) {\n      const data = await response.json()\n      config.value = data\n      announcements.value = data.announcements || []\n    }\n    retrievedConfig.value = true\n  } catch (error) {\n    console.error('Failed to fetch config:', error)\n    retrievedConfig.value = true\n  }\n}\n\nconst showTooltip = (result, event, action = 'hover') => {\n  if (action === 'click') {\n    if (!result) {\n      // Deselecting\n      tooltip.value = {}\n      tooltipIsPersistent.value = false\n    } else {\n      // Selecting new data point\n      tooltip.value = { result, event }\n      tooltipIsPersistent.value = true\n    }\n  } else if (action === 'hover') {\n    // Only update tooltip on hover if not in persistent mode\n    if (!tooltipIsPersistent.value) {\n      tooltip.value = { result, event }\n    }\n  }\n}\n\nconst handleDocumentClick = (event) => {\n  // Close persistent tooltip when clicking outside\n  if (tooltipIsPersistent.value) {\n    const tooltipElement = document.getElementById('tooltip')\n    // Check if click is on a data point bar or inside tooltip\n    const clickedDataPoint = event.target.closest('.flex-1.h-6, .flex-1.h-8')\n\n    if (tooltipElement && !tooltipElement.contains(event.target) && !clickedDataPoint) {\n      tooltip.value = {}\n      tooltipIsPersistent.value = false\n      // Emit event to clear selections in child components\n      window.dispatchEvent(new CustomEvent('clear-data-point-selection'))\n    }\n  }\n}\n\n// Fetch config on mount and set up interval\nonMounted(() => {\n  fetchConfig()\n  // Refresh config every 10 minutes for announcements\n  configInterval = setInterval(fetchConfig, 600000)\n  // Add click listener for closing persistent tooltips\n  document.addEventListener('click', handleDocumentClick)\n})\n\n// Clean up interval on unmount\nonUnmounted(() => {\n  if (configInterval) {\n    clearInterval(configInterval)\n    configInterval = null\n  }\n  // Remove click listener\n  document.removeEventListener('click', handleDocumentClick)\n})\n</script>"
  },
  {
    "path": "web/app/src/components/AnnouncementBanner.vue",
    "content": "<template>\n  <div v-if=\"announcements && announcements.length\" class=\"announcement-container mb-6\">\n    <div \n      :class=\"[\n        'rounded-lg border bg-card text-card-foreground shadow-sm transition-all duration-200',\n        containerClasses\n      ]\"\n    >\n      <!-- Header -->\n      <div \n        :class=\"[\n          'announcement-header px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors',\n          isCollapsed ? 'rounded-lg' : 'rounded-t-lg border-b border-gray-200 dark:border-gray-600'\n        ]\"\n        @click=\"toggleCollapsed\"\n      >\n        <div class=\"flex items-center justify-between\">\n          <div class=\"flex items-center gap-2\">\n            <component :is=\"mostRecentIcon\" :class=\"['w-5 h-5', mostRecentIconClass]\" />\n            <h2 class=\"text-base font-semibold text-gray-900 dark:text-gray-100\">Announcements</h2>\n            <span class=\"text-xs text-gray-500 dark:text-gray-400\">\n              ({{ announcements.length }})\n            </span>\n          </div>\n          <ChevronDown \n            :class=\"[\n              'w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform duration-200',\n              isCollapsed ? '-rotate-90' : 'rotate-0'\n            ]\"\n          />\n        </div>\n      </div>\n\n      <!-- Timeline Content -->\n      <div \n        v-if=\"!isCollapsed\"\n        class=\"announcement-content p-4 transition-all duration-200 rounded-b-lg\"\n      >\n        <div class=\"relative\">\n          <!-- Announcements -->\n          <div class=\"space-y-3\">\n            <div\n              v-for=\"(group, date) in groupedAnnouncements\"\n              :key=\"date\"\n              class=\"relative\"\n            >\n              <!-- Date Header -->\n              <div class=\"flex items-center gap-3 mb-2 relative\">\n                <div class=\"relative z-10 bg-white dark:bg-gray-800 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-600\">\n                  <time class=\"text-sm font-medium text-gray-600 dark:text-gray-300\">\n                    {{ formatDate(date) }}\n                  </time>\n                </div>\n                <div class=\"flex-1 border-t border-gray-200 dark:border-gray-600\"></div>\n              </div>\n\n              <!-- Announcements for this date -->\n              <div class=\"space-y-2 ml-7 relative\">\n                <div\n                  v-for=\"(announcement, index) in group\"\n                  :key=\"`${date}-${index}-${announcement.timestamp}`\"\n                  class=\"relative\"\n                >\n                  <!-- Timeline Icon -->\n                  <div\n                    :class=\"[\n                      'absolute -left-[26px] w-5 h-5 rounded-full border bg-white dark:bg-gray-800 flex items-center justify-center z-10',\n                      index === group.length - 1 ? 'top-3' : 'top-1/2 -translate-y-1/2',\n                      getTypeClasses(announcement.type).border\n                    ]\"\n                  >\n                    <component\n                      :is=\"getTypeIcon(announcement.type)\"\n                      :class=\"['w-3 h-3', getTypeClasses(announcement.type).iconColor]\"\n                    />\n                  </div>\n\n                  <!-- Vertical line segment connecting upward from first icon to date -->\n                  <div\n                    v-if=\"index === 0\"\n                    class=\"absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none\"\n                    style=\"left: -16px; top: -2.5rem; height: calc(50% + 2.5rem);\"\n                  ></div>\n\n                  <!-- Vertical line segment connecting downward to next icon -->\n                  <div\n                    v-if=\"index < group.length - 1\"\n                    class=\"absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none\"\n                    :style=\"{\n                      left: '-16px',\n                      top: '50%',\n                      height: index === group.length - 2 ? 'calc(50% + 1.25rem)' : 'calc(50% + 2rem)'\n                    }\"\n                  ></div>\n\n                  <!-- Announcement Card -->\n                  <div\n                    :class=\"[\n                      'rounded-md border p-3 transition-all duration-200 hover:shadow-sm',\n                      getTypeClasses(announcement.type).background\n                    ]\"\n                  >\n                    <div class=\"flex items-center gap-3\">\n                      <time\n                        :class=\"[\n                          'text-sm font-mono whitespace-nowrap flex-shrink-0',\n                          getTypeClasses(announcement.type).text\n                        ]\"\n                        :title=\"formatFullTimestamp(announcement.timestamp)\"\n                      >\n                        {{ formatTime(announcement.timestamp) }}\n                      </time>\n                      <div class=\"flex-1 min-w-0\">\n                        <p\n                          class=\"text-sm leading-relaxed text-gray-900 dark:text-gray-100\"\n                          v-html=\"formatAnnouncementMessage(announcement.message)\"\n                        ></p>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed, ref } from 'vue'\nimport { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'\nimport { formatAnnouncementMessage } from '@/utils/markdown'\n\n// Props\nconst props = defineProps({\n  announcements: {\n    type: Array,\n    default: () => []\n  }\n})\n\n// Collapse state\nconst isCollapsed = ref(false)\n\n// Methods\nconst toggleCollapsed = () => {\n  isCollapsed.value = !isCollapsed.value\n}\n\n// Type configurations\nconst typeConfigs = {\n  outage: {\n    icon: XCircle,\n    background: 'bg-red-50 border-gray-200 dark:bg-red-900/50 dark:border-gray-600',\n    border: 'border-red-500',\n    iconColor: 'text-red-600 dark:text-red-400',\n    text: 'text-red-700 dark:text-red-300'\n  },\n  warning: {\n    icon: AlertTriangle,\n    background: 'bg-yellow-50 border-gray-200 dark:bg-yellow-900/50 dark:border-gray-600',\n    border: 'border-yellow-500',\n    iconColor: 'text-yellow-600 dark:text-yellow-400',\n    text: 'text-yellow-700 dark:text-yellow-300'\n  },\n  information: {\n    icon: Info,\n    background: 'bg-blue-50 border-gray-200 dark:bg-blue-900/50 dark:border-gray-600',\n    border: 'border-blue-500',\n    iconColor: 'text-blue-600 dark:text-blue-400',\n    text: 'text-blue-700 dark:text-blue-300'\n  },\n  operational: {\n    icon: CheckCircle,\n    background: 'bg-green-50 border-gray-200 dark:bg-green-900/50 dark:border-gray-600',\n    border: 'border-green-500',\n    iconColor: 'text-green-600 dark:text-green-400',\n    text: 'text-green-700 dark:text-green-300'\n  },\n  none: {\n    icon: Circle,\n    background: 'bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-600',\n    border: 'border-gray-500',\n    iconColor: 'text-gray-600 dark:text-gray-400',\n    text: 'text-gray-700 dark:text-gray-300'\n  }\n}\n\n// Computed properties\nconst mostRecentAnnouncement = computed(() => {\n  return props.announcements && props.announcements.length > 0 ? props.announcements[0] : null\n})\n\nconst mostRecentIcon = computed(() => {\n  const type = mostRecentAnnouncement.value?.type || 'none'\n  return typeConfigs[type]?.icon || Circle\n})\n\nconst mostRecentIconClass = computed(() => {\n  const type = mostRecentAnnouncement.value?.type || 'none'\n  return typeConfigs[type]?.iconColor || 'text-gray-600 dark:text-gray-400'\n})\n\nconst containerClasses = computed(() => {\n  const type = mostRecentAnnouncement.value?.type || 'none'\n  const config = typeConfigs[type]\n  // Add a subtle left border accent to indicate announcement type\n  return `border-l-4 ${config.border.replace('border-', 'border-l-')}`\n})\n\nconst groupedAnnouncements = computed(() => {\n  if (!props.announcements || props.announcements.length === 0) {\n    return {}\n  }\n\n  const groups = {}\n  props.announcements.forEach(announcement => {\n    const date = new Date(announcement.timestamp).toDateString()\n    if (!groups[date]) {\n      groups[date] = []\n    }\n    groups[date].push(announcement)\n  })\n\n  return groups\n})\n\n// Helper functions\nconst getTypeIcon = (type) => {\n  return typeConfigs[type]?.icon || Circle\n}\n\nconst getTypeClasses = (type) => {\n  return typeConfigs[type] || typeConfigs.none\n}\n\nconst formatDate = (dateString) => {\n  const date = new Date(dateString)\n  const today = new Date()\n  const yesterday = new Date(today)\n  yesterday.setDate(yesterday.getDate() - 1)\n\n  if (date.toDateString() === today.toDateString()) {\n    return 'Today'\n  } else if (date.toDateString() === yesterday.toDateString()) {\n    return 'Yesterday'\n  } else {\n    return date.toLocaleDateString('en-US', {\n      weekday: 'long',\n      year: 'numeric',\n      month: 'long',\n      day: 'numeric'\n    })\n  }\n}\n\nconst formatTime = (timestamp) => {\n  return new Date(timestamp).toLocaleTimeString('en-US', {\n    hour: '2-digit',\n    minute: '2-digit',\n    hour12: false\n  })\n}\n\nconst formatFullTimestamp = (timestamp) => {\n  return new Date(timestamp).toLocaleString('en-US', {\n    year: 'numeric',\n    month: 'long',\n    day: 'numeric',\n    hour: '2-digit',\n    minute: '2-digit',\n    second: '2-digit',\n    timeZoneName: 'short'\n  })\n}\n</script>\n\n<style scoped>\n.announcement-container {\n  animation: slideDown 0.3s ease-out;\n}\n\n@keyframes slideDown {\n  from {\n    opacity: 0;\n    transform: translateY(-10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n/* Responsive adjustments */\n@media (max-width: 640px) {\n  .announcement-container .ml-7 {\n    margin-left: 1.5rem;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/app/src/components/EndpointCard.vue",
    "content": "<template>\n  <Card class=\"endpoint h-full flex flex-col transition hover:shadow-lg hover:scale-[1.01] dark:hover:border-gray-700\">\n    <CardHeader class=\"endpoint-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0\">\n      <div class=\"flex items-start justify-between gap-2 sm:gap-3\">\n        <div class=\"flex-1 min-w-0 overflow-hidden\">\n          <CardTitle class=\"text-base sm:text-lg truncate\">\n            <span \n              class=\"hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate\" \n              @click=\"navigateToDetails\" \n              @keydown.enter=\"navigateToDetails\"\n              :title=\"endpoint.name\"\n              role=\"link\"\n              tabindex=\"0\"\n              :aria-label=\"`View details for ${endpoint.name}`\">\n              {{ endpoint.name }}\n            </span>\n          </CardTitle>\n          <div class=\"flex items-center gap-2 text-xs sm:text-sm text-muted-foreground min-h-[1.25rem]\">\n            <span v-if=\"endpoint.group\" class=\"truncate\" :title=\"endpoint.group\">{{ endpoint.group }}</span>\n            <span v-if=\"endpoint.group && hostname\">•</span>\n            <span v-if=\"hostname\" class=\"truncate\" :title=\"hostname\">{{ hostname }}</span>\n          </div>\n        </div>\n        <div class=\"flex-shrink-0 ml-2\">\n          <StatusBadge :status=\"currentStatus\" />\n        </div>\n      </div>\n    </CardHeader>\n    <CardContent class=\"endpoint-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2\">\n      <div class=\"space-y-2\">\n        <div>\n          <div class=\"flex items-center justify-between mb-1\">\n            <div class=\"flex-1\"></div>\n            <p class=\"text-xs text-muted-foreground\" :title=\"showAverageResponseTime ? 'Average response time' : 'Minimum and maximum response time'\">{{ formattedResponseTime }}</p>\n          </div>\n          <div class=\"flex gap-0.5\">\n            <div\n              v-for=\"(result, index) in displayResults\"\n              :key=\"index\"\n              :class=\"[\n                'flex-1 h-6 sm:h-8 rounded-sm transition-all',\n                result ? 'cursor-pointer' : '',\n                result ? (\n                  result.success \n                    ? (selectedResultIndex === index ? 'bg-green-700' : 'bg-green-500 hover:bg-green-700')\n                    : (selectedResultIndex === index ? 'bg-red-700' : 'bg-red-500 hover:bg-red-700')\n                ) : 'bg-gray-200 dark:bg-gray-700'\n              ]\"\n              @mouseenter=\"result && handleMouseEnter(result, $event)\"\n              @mouseleave=\"result && handleMouseLeave(result, $event)\"\n              @click.stop=\"result && handleClick(result, $event, index)\"\n            />\n          </div>\n          <div class=\"flex items-center justify-between text-xs text-muted-foreground mt-1\">\n            <span>{{ oldestResultTime }}</span>\n            <span>{{ newestResultTime }}</span>\n          </div>\n        </div>\n      </div>\n    </CardContent>\n  </Card>\n</template>\n\n<script setup>\nimport { computed, ref, onMounted, onUnmounted } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'\nimport StatusBadge from '@/components/StatusBadge.vue'\nimport { generatePrettyTimeAgo } from '@/utils/time'\n\nconst router = useRouter()\n\nconst props = defineProps({\n  endpoint: {\n    type: Object,\n    required: true\n  },\n  maxResults: {\n    type: Number,\n    default: 50\n  },\n  showAverageResponseTime: {\n    type: Boolean,\n    default: true\n  }\n})\n\nconst emit = defineEmits(['showTooltip'])\n\n// Track selected data point\nconst selectedResultIndex = ref(null)\n\nconst latestResult = computed(() => {\n  if (!props.endpoint.results || props.endpoint.results.length === 0) {\n    return null\n  }\n  return props.endpoint.results[props.endpoint.results.length - 1]\n})\n\nconst currentStatus = computed(() => {\n  if (!latestResult.value) return 'unknown'\n  return latestResult.value.success ? 'healthy' : 'unhealthy'\n})\n\nconst hostname = computed(() => {\n  return latestResult.value?.hostname || null\n})\n\nconst displayResults = computed(() => {\n  const results = [...(props.endpoint.results || [])]\n  while (results.length < props.maxResults) {\n    results.unshift(null)\n  }\n  return results.slice(-props.maxResults)\n})\n\nconst formattedResponseTime = computed(() => {\n  if (!props.endpoint.results || props.endpoint.results.length === 0) {\n    return 'N/A'\n  }\n  \n  let total = 0\n  let count = 0\n  let min = Infinity\n  let max = 0\n  \n  for (const result of props.endpoint.results) {\n    if (result.duration) {\n      const durationMs = result.duration / 1000000\n      total += durationMs\n      count++\n      min = Math.min(min, durationMs)\n      max = Math.max(max, durationMs)\n    }\n  }\n  \n  if (count === 0) return 'N/A'\n  \n  if (props.showAverageResponseTime) {\n    const avgMs = Math.round(total / count)\n    return `~${avgMs}ms`\n  } else {\n    // Show min-max range\n    const minMs = Math.trunc(min)\n    const maxMs = Math.trunc(max)\n    // If min and max are the same, show single value\n    if (minMs === maxMs) {\n      return `${minMs}ms`\n    }\n    return `${minMs}-${maxMs}ms`\n  }\n})\n\nconst oldestResultTime = computed(() => {\n  if (!props.endpoint.results || props.endpoint.results.length === 0) return ''\n  const oldestResultIndex = Math.max(0, props.endpoint.results.length - props.maxResults)\n  return generatePrettyTimeAgo(props.endpoint.results[oldestResultIndex].timestamp)\n})\n\nconst newestResultTime = computed(() => {\n  if (!props.endpoint.results || props.endpoint.results.length === 0) return ''\n  return generatePrettyTimeAgo(props.endpoint.results[props.endpoint.results.length - 1].timestamp)\n})\n\nconst navigateToDetails = () => {\n  router.push(`/endpoints/${props.endpoint.key}`)\n}\n\nconst handleMouseEnter = (result, event) => {\n  emit('showTooltip', result, event, 'hover')\n}\n\nconst handleMouseLeave = (result, event) => {\n  emit('showTooltip', null, event, 'hover')\n}\n\nconst handleClick = (result, event, index) => {\n  // Clear selections in other cards first\n  window.dispatchEvent(new CustomEvent('clear-data-point-selection'))\n  // Then toggle this card's selection\n  if (selectedResultIndex.value === index) {\n    selectedResultIndex.value = null\n    emit('showTooltip', null, event, 'click')\n  } else {\n    selectedResultIndex.value = index\n    emit('showTooltip', result, event, 'click')\n  }\n}\n\n// Listen for clear selection event\nconst handleClearSelection = () => {\n  selectedResultIndex.value = null\n}\n\nonMounted(() => {\n  window.addEventListener('clear-data-point-selection', handleClearSelection)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('clear-data-point-selection', handleClearSelection)\n})\n</script>"
  },
  {
    "path": "web/app/src/components/FlowStep.vue",
    "content": "<template>\n  <div class=\"flex items-start gap-4 relative group hover:bg-accent/30 rounded-lg p-2 -m-2 transition-colors cursor-pointer\"\n       @click=\"$emit('step-click')\">\n    <!-- Step circle with status icon -->\n    <div class=\"relative flex-shrink-0\">\n      <!-- Connection line from previous step -->\n      <div v-if=\"index > 0\" :class=\"incomingLineClasses\" class=\"absolute left-1/2 bottom-8 w-0.5 h-4 -translate-x-px\"></div>\n      \n      <div :class=\"circleClasses\" class=\"w-8 h-8 rounded-full flex items-center justify-center\">\n        <component :is=\"statusIcon\" class=\"w-4 h-4\" />\n      </div>\n      \n      <!-- Connection line to next step -->\n      <div v-if=\"!isLast\" :class=\"connectionLineClasses\" class=\"absolute left-1/2 top-8 w-0.5 h-4 -translate-x-px\"></div>\n    </div>\n    \n    <!-- Step content -->\n    <div class=\"flex-1 min-w-0 pt-1\">\n      <div class=\"flex items-center justify-between gap-2 mb-1\">\n        <h4 class=\"font-medium text-sm truncate\">{{ step.name }}</h4>\n        <span class=\"text-xs text-muted-foreground whitespace-nowrap\">\n          {{ formatDuration(step.duration) }}\n        </span>\n      </div>\n      \n      <!-- Step badges -->\n      <div class=\"flex flex-wrap gap-1\">\n        <span v-if=\"step.isAlwaysRun\" 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\">\n          <RotateCcw class=\"w-3 h-3\" />\n          Always Run\n        </span>\n        <span v-if=\"step.errors?.length\" 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\">\n          {{ step.errors.length }} error{{ step.errors.length !== 1 ? 's' : '' }}\n        </span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { CheckCircle, XCircle, SkipForward, RotateCcw, Pause } from 'lucide-vue-next'\nimport { formatDuration } from '@/utils/format'\n\nconst props = defineProps({\n  step: { type: Object, required: true },\n  index: { type: Number, required: true },\n  isLast: { type: Boolean, default: false },\n  previousStep: { type: Object, default: null }\n})\n\ndefineEmits(['step-click'])\n\n// Status icon mapping\nconst statusIcon = computed(() => {\n  switch (props.step.status) {\n    case 'success': return CheckCircle\n    case 'failed': return XCircle\n    case 'skipped': return SkipForward\n    case 'not-started': return Pause\n    default: return Pause\n  }\n})\n\n// Circle styling classes\nconst circleClasses = computed(() => {\n  const baseClasses = 'border-2'\n  \n  if (props.step.isAlwaysRun) {\n    // Always-run endpoints get a special ring effect\n    switch (props.step.status) {\n      case 'success':\n        return `${baseClasses} bg-green-500 text-white border-green-600 ring-2 ring-blue-200 dark:ring-blue-800`\n      case 'failed':\n        return `${baseClasses} bg-red-500 text-white border-red-600 ring-2 ring-blue-200 dark:ring-blue-800`\n      default:\n        return `${baseClasses} bg-blue-500 text-white border-blue-600 ring-2 ring-blue-200 dark:ring-blue-800`\n    }\n  }\n  \n  switch (props.step.status) {\n    case 'success':\n      return `${baseClasses} bg-green-500 text-white border-green-600`\n    case 'failed':\n      return `${baseClasses} bg-red-500 text-white border-red-600`\n    case 'skipped':\n      return `${baseClasses} bg-gray-400 text-white border-gray-500`\n    case 'not-started':\n      return `${baseClasses} bg-gray-200 text-gray-500 border-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600`\n    default:\n      return `${baseClasses} bg-gray-200 text-gray-500 border-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600`\n  }\n})\n\n// Incoming connection line styling (from previous step to this step)\nconst incomingLineClasses = computed(() => {\n  if (!props.previousStep) return 'bg-gray-300 dark:bg-gray-600'\n  \n  // If this step is skipped, the line should be dashed/gray\n  if (props.step.status === 'skipped') {\n    return 'border-l-2 border-dashed border-gray-400 bg-transparent'\n  }\n  \n  // Otherwise, color based on previous step's status\n  switch (props.previousStep.status) {\n    case 'success':\n      return 'bg-green-500'\n    case 'failed':\n      // If previous failed but this ran (always-run), show red line\n      return 'bg-red-500'\n    default:\n      return 'bg-gray-300 dark:bg-gray-600'\n  }\n})\n\n// Outgoing connection line styling (from this step to next)\nconst connectionLineClasses = computed(() => {\n  const nextStep = props.step.nextStepStatus\n  switch (props.step.status) {\n    case 'success':\n      return nextStep === 'skipped' \n        ? 'bg-gray-300 dark:bg-gray-600' \n        : 'bg-green-500'\n    case 'failed':\n      return nextStep === 'skipped'\n        ? 'border-l-2 border-dashed border-gray-400 bg-transparent'\n        : 'bg-red-500'\n    default:\n      return 'bg-gray-300 dark:bg-gray-600'\n  }\n})\n\n</script>"
  },
  {
    "path": "web/app/src/components/Loading.vue",
    "content": "<template>\n  <div class=\"flex justify-center items-center\">\n    <img \n      :class=\"[\n        'animate-spin rounded-full opacity-60 grayscale',\n        sizeClass,\n      ]\"\n      src=\"../assets/logo.svg\" \n      alt=\"Gatus logo\" \n    />\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\n\nconst props = defineProps({\n  size: {\n    type: String,\n    default: 'md',\n    validator: (value) => ['xs', 'sm', 'md', 'lg', 'xl'].includes(value)\n  },\n})\n\nconst sizeClass = computed(() => {\n  const sizes = {\n    xs: 'w-4 h-4',\n    sm: 'w-6 h-6',\n    md: 'w-8 h-8',\n    lg: 'w-12 h-12',\n    xl: 'w-16 h-16'\n  }\n  return sizes[props.size] || sizes.md\n})\n</script>"
  },
  {
    "path": "web/app/src/components/Pagination.vue",
    "content": "<template>\n  <div class=\"flex items-center justify-between\">\n    <Button\n      variant=\"outline\"\n      size=\"sm\"\n      :disabled=\"currentPage >= maxPages\"\n      @click=\"previousPage\"\n      class=\"flex items-center gap-1\"\n    >\n      <ChevronLeft class=\"h-4 w-4\" />\n      Previous\n    </Button>\n    \n    <span class=\"text-sm text-muted-foreground\">\n      Page {{ currentPage }} of {{ maxPages }}\n    </span>\n    \n    <Button\n      variant=\"outline\"\n      size=\"sm\"\n      :disabled=\"currentPage <= 1\"\n      @click=\"nextPage\"\n      class=\"flex items-center gap-1\"\n    >\n      Next\n      <ChevronRight class=\"h-4 w-4\" />\n    </Button>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed } from 'vue'\nimport { ChevronLeft, ChevronRight } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\n\nconst props = defineProps({\n  numberOfResultsPerPage: Number,\n  currentPageProp: {\n    type: Number,\n    default: 1\n  }\n})\n\nconst emit = defineEmits(['page'])\n\nconst currentPage = ref(props.currentPageProp)\n\nconst maxPages = computed(() => {\n  // Use maximumNumberOfResults from config if available, otherwise default to 100\n  let maxResults = 100 // Default value\n  // Check if window.config exists and has maximumNumberOfResults\n  if (typeof window !== 'undefined' && window.config && window.config.maximumNumberOfResults) {\n    const parsed = parseInt(window.config.maximumNumberOfResults)\n    if (!isNaN(parsed)) {\n      maxResults = parsed\n    }\n  }\n  return Math.ceil(maxResults / props.numberOfResultsPerPage)\n})\n\nconst nextPage = () => {\n  // \"Next\" should show newer data (lower page numbers)\n  currentPage.value--\n  emit('page', currentPage.value)\n}\n\nconst previousPage = () => {\n  // \"Previous\" should show older data (higher page numbers)\n  currentPage.value++\n  emit('page', currentPage.value)\n}\n</script>"
  },
  {
    "path": "web/app/src/components/PastAnnouncements.vue",
    "content": "<template>\n  <div v-if=\"announcements && announcements.length\" class=\"past-announcements\">\n    <h2 class=\"text-2xl font-semibold text-foreground mb-6\">Past Announcements</h2>\n\n    <div class=\"space-y-8\">\n      <div\n        v-for=\"(group, date) in displayedAnnouncements\"\n        :key=\"date\"\n      >\n        <!-- Date Header -->\n        <div class=\"mb-3\">\n          <h3 class=\"text-sm font-semibold text-muted-foreground uppercase tracking-wider\">\n            {{ formatDate(date) }}\n          </h3>\n        </div>\n\n        <!-- Announcements for this date or empty state -->\n        <div v-if=\"group.length > 0\" class=\"space-y-3\">\n          <div\n            v-for=\"(announcement, index) in group\"\n            :key=\"`${date}-${index}-${announcement.timestamp}`\"\n            :class=\"[\n              'border-l-4 p-4 transition-all duration-200',\n              getTypeClasses(announcement.type).background,\n              getTypeClasses(announcement.type).borderColor\n            ]\"\n          >\n            <div class=\"flex items-start gap-3\">\n              <component\n                :is=\"getTypeIcon(announcement.type)\"\n                :class=\"['w-5 h-5 flex-shrink-0 mt-0.5', getTypeClasses(announcement.type).iconColor]\"\n              />\n              <time\n                :class=\"[\n                  'text-sm font-mono whitespace-nowrap flex-shrink-0 mt-0.5',\n                  getTypeClasses(announcement.type).text\n                ]\"\n                :title=\"formatFullTimestamp(announcement.timestamp)\"\n              >\n                {{ formatTime(announcement.timestamp) }}\n              </time>\n              <div class=\"flex-1 min-w-0\">\n                <p\n                  class=\"text-sm leading-relaxed text-gray-900 dark:text-gray-100\"\n                  v-html=\"formatAnnouncementMessage(announcement.message)\"\n                ></p>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Empty state for dates without announcements -->\n        <div v-else class=\"py-2\">\n          <p class=\"text-sm italic text-muted-foreground/60\">\n            No incidents reported on this day\n          </p>\n        </div>\n      </div>\n\n      <!-- View Older Announcements Link -->\n      <div v-if=\"hasOlderAnnouncements && !showAllAnnouncements\">\n        <button @click=\"showAllAnnouncements = true\" class=\"inline-flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors duration-200 cursor-pointer group\">\n          <ChevronDown class=\"w-4 h-4 group-hover:translate-y-0.5 transition-transform duration-200\" />\n          <span class=\"group-hover:underline\">View older announcements</span>\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed } from 'vue'\nimport { XCircle, AlertTriangle, Info, CheckCircle, Circle, ChevronDown } from 'lucide-vue-next'\nimport { formatAnnouncementMessage } from '@/utils/markdown'\n\n// Props\nconst props = defineProps({\n  announcements: {\n    type: Array,\n    default: () => []\n  }\n})\n\n// State\nconst showAllAnnouncements = ref(false)\n\n// Type configurations (consistent with AnnouncementBanner)\nconst typeConfigs = {\n  outage: {\n    icon: XCircle,\n    background: 'bg-red-50 dark:bg-red-900/20',\n    borderColor: 'border-red-500 dark:border-red-400',\n    iconColor: 'text-red-600 dark:text-red-400',\n    text: 'text-red-700 dark:text-red-300'\n  },\n  warning: {\n    icon: AlertTriangle,\n    background: 'bg-yellow-50 dark:bg-yellow-900/20',\n    borderColor: 'border-yellow-500 dark:border-yellow-400',\n    iconColor: 'text-yellow-600 dark:text-yellow-400',\n    text: 'text-yellow-700 dark:text-yellow-300'\n  },\n  information: {\n    icon: Info,\n    background: 'bg-blue-50 dark:bg-blue-900/20',\n    borderColor: 'border-blue-500 dark:border-blue-400',\n    iconColor: 'text-blue-600 dark:text-blue-400',\n    text: 'text-blue-700 dark:text-blue-300'\n  },\n  operational: {\n    icon: CheckCircle,\n    background: 'bg-green-50 dark:bg-green-900/20',\n    borderColor: 'border-green-500 dark:border-green-400',\n    iconColor: 'text-green-600 dark:text-green-400',\n    text: 'text-green-700 dark:text-green-300'\n  },\n  none: {\n    icon: Circle,\n    background: 'bg-gray-50 dark:bg-gray-800/20',\n    borderColor: 'border-gray-500 dark:border-gray-400',\n    iconColor: 'text-gray-600 dark:text-gray-400',\n    text: 'text-gray-700 dark:text-gray-300'\n  }\n}\n\n// Helper to normalize date to start of day\nconst normalizeDate = (date) => {\n  const normalized = new Date(date)\n  normalized.setHours(0, 0, 0, 0)\n  return normalized\n}\n\n// Computed properties\nconst displayedAnnouncements = computed(() => {\n  if (!props.announcements?.length) return {}\n\n  // Group announcements by date and find oldest\n  const grouped = {}\n  let oldest = new Date()\n\n  props.announcements.forEach(announcement => {\n    const date = new Date(announcement.timestamp)\n    const key = date.toDateString()\n    grouped[key] = grouped[key] || []\n    grouped[key].push(announcement)\n    if (date < oldest) oldest = date\n  })\n\n  // Calculate date range\n  const today = normalizeDate(new Date())\n  const endDate = showAllAnnouncements.value\n    ? normalizeDate(oldest)\n    : new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000)\n\n  // Build result: today (if has announcements) + yesterday backwards\n  const result = {}\n  const todayKey = today.toDateString()\n  if (grouped[todayKey]) result[todayKey] = grouped[todayKey]\n\n  for (let date = new Date(today.getTime() - 24 * 60 * 60 * 1000); date >= endDate; date.setDate(date.getDate() - 1)) {\n    result[date.toDateString()] = grouped[date.toDateString()] || []\n  }\n\n  return result\n})\n\n// Check if there are announcements older than 14 days\nconst hasOlderAnnouncements = computed(() => {\n  if (!props.announcements?.length) return false\n  const fourteenDaysAgo = new Date(normalizeDate(new Date()).getTime() - 14 * 24 * 60 * 60 * 1000)\n  return props.announcements.some(a => new Date(a.timestamp) < fourteenDaysAgo)\n})\n\n// Helper functions\nconst getTypeIcon = (type) => {\n  return typeConfigs[type]?.icon || Circle\n}\n\nconst getTypeClasses = (type) => {\n  return typeConfigs[type] || typeConfigs.none\n}\n\nconst formatDate = (dateString) => {\n  const date = new Date(dateString)\n  return date.toLocaleDateString('en-US', {\n    weekday: 'long',\n    year: 'numeric',\n    month: 'long',\n    day: 'numeric'\n  })\n}\n\nconst formatTime = (timestamp) => {\n  return new Date(timestamp).toLocaleTimeString('en-US', {\n    hour: '2-digit',\n    minute: '2-digit',\n    hour12: false\n  })\n}\n\nconst formatFullTimestamp = (timestamp) => {\n  return new Date(timestamp).toLocaleString('en-US', {\n    year: 'numeric',\n    month: 'long',\n    day: 'numeric',\n    hour: '2-digit',\n    minute: '2-digit',\n    second: '2-digit',\n    timeZoneName: 'short'\n  })\n}\n</script>\n"
  },
  {
    "path": "web/app/src/components/ResponseTimeChart.vue",
    "content": "<template>\n  <div class=\"relative w-full\" style=\"height: 300px;\">\n    <div v-if=\"loading\" class=\"absolute inset-0 flex items-center justify-center bg-background/50\">\n      <Loading />\n    </div>\n    <div v-else-if=\"error\" class=\"absolute inset-0 flex items-center justify-center text-muted-foreground\">\n      {{ error }}\n    </div>\n    <Line v-else :data=\"chartData\" :options=\"chartOptions\" />\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, watch, onMounted, onUnmounted } from 'vue'\nimport { Line } from 'vue-chartjs'\nimport { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale } from 'chart.js'\nimport annotationPlugin from 'chartjs-plugin-annotation'\nimport 'chartjs-adapter-date-fns'\nimport { generatePrettyTimeDifference } from '@/utils/time'\nimport Loading from './Loading.vue'\n\nChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, TimeScale, annotationPlugin)\n\nconst props = defineProps({\n  endpointKey: {\n    type: String,\n    required: true\n  },\n  duration: {\n    type: String,\n    required: true,\n    validator: (value) => ['24h', '7d', '30d'].includes(value)\n  },\n  serverUrl: {\n    type: String,\n    default: '..'\n  },\n  events: {\n    type: Array,\n    default: () => []\n  }\n})\n\nconst loading = ref(true)\nconst error = ref(null)\nconst timestamps = ref([])\nconst values = ref([])\nconst isDark = ref(document.documentElement.classList.contains('dark'))\nconst hoveredEventIndex = ref(null)\n\n// Helper function to get color for unhealthy events\nconst getEventColor = () => {\n  // Only UNHEALTHY events are displayed on the chart\n  return 'rgba(239, 68, 68, 0.8)' // Red\n}\n\n// Filter events based on selected duration and calculate durations\nconst filteredEvents = computed(() => {\n  if (!props.events || props.events.length === 0) {\n    return []\n  }\n\n  const now = new Date()\n  let fromTime\n  switch (props.duration) {\n    case '24h':\n      fromTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n      break\n    case '7d':\n      fromTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)\n      break\n    case '30d':\n      fromTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)\n      break\n    default:\n      return []\n  }\n\n  // Only include UNHEALTHY events and calculate their duration\n  const unhealthyEvents = []\n  for (let i = 0; i < props.events.length; i++) {\n    const event = props.events[i]\n    if (event.type !== 'UNHEALTHY') continue\n\n    const eventTime = new Date(event.timestamp)\n    if (eventTime < fromTime || eventTime > now) continue\n\n    // Find the next event to calculate duration\n    let duration = null\n    let isOngoing = false\n    if (i + 1 < props.events.length) {\n      const nextEvent = props.events[i + 1]\n      duration = generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)\n    } else {\n      // Still ongoing - calculate duration from event time to now\n      duration = generatePrettyTimeDifference(now, event.timestamp)\n      isOngoing = true\n    }\n\n    unhealthyEvents.push({\n      ...event,\n      duration,\n      isOngoing\n    })\n  }\n\n  return unhealthyEvents\n})\n\nconst chartData = computed(() => {\n  if (timestamps.value.length === 0) {\n    return {\n      labels: [],\n      datasets: []\n    }\n  }\n  const labels = timestamps.value.map(ts => new Date(ts))\n  return {\n    labels,\n    datasets: [{\n      label: 'Response Time (ms)',\n      data: values.value,\n      borderColor: isDark.value ? 'rgb(96, 165, 250)' : 'rgb(59, 130, 246)',\n      backgroundColor: isDark.value ? 'rgba(96, 165, 250, 0.1)' : 'rgba(59, 130, 246, 0.1)',\n      borderWidth: 2,\n      pointRadius: 2,\n      pointHoverRadius: 4,\n      tension: 0.1,\n      fill: true\n    }]\n  }\n})\n\nconst chartOptions = computed(() => {\n  // Include hoveredEventIndex in dependency tracking\n  // eslint-disable-next-line no-unused-vars\n  const _ = hoveredEventIndex.value\n\n  // Calculate max Y value for positioning annotations\n  const maxY = values.value.length > 0 ? Math.max(...values.value) : 0\n  const midY = maxY / 2\n\n  return {\n    responsive: true,\n    maintainAspectRatio: false,\n    interaction: {\n      mode: 'index',\n      intersect: false\n    },\n    plugins: {\n      legend: {\n        display: false\n      },\n      tooltip: {\n        backgroundColor: isDark.value ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',\n        titleColor: isDark.value ? '#f9fafb' : '#111827',\n        bodyColor: isDark.value ? '#d1d5db' : '#374151',\n        borderColor: isDark.value ? '#4b5563' : '#e5e7eb',\n        borderWidth: 1,\n        padding: 12,\n        displayColors: false,\n        callbacks: {\n          title: (tooltipItems) => {\n            if (tooltipItems.length > 0) {\n              const date = new Date(tooltipItems[0].parsed.x)\n              return date.toLocaleString()\n            }\n            return ''\n          },\n          label: (context) => {\n            const value = context.parsed.y\n            return `${value}ms`\n          }\n        }\n      },\n      annotation: {\n        annotations: filteredEvents.value.reduce((acc, event, index) => {\n          // Find closest data point to determine annotation position\n          const eventTimestamp = new Date(event.timestamp).getTime()\n          let closestValue = 0\n\n          if (timestamps.value.length > 0 && values.value.length > 0) {\n            const closestIndex = timestamps.value.reduce((closest, ts, idx) => {\n              const tsTime = new Date(ts).getTime()\n              const currentDistance = Math.abs(tsTime - eventTimestamp)\n              const closestDistance = Math.abs(new Date(timestamps.value[closest]).getTime() - eventTimestamp)\n              return currentDistance < closestDistance ? idx : closest\n            }, 0)\n            closestValue = values.value[closestIndex]\n          }\n\n          // Position annotation at bottom if data point is in lower half, at top if in upper half\n          const position = closestValue <= midY ? 'end' : 'start'\n\n          acc[`event-${index}`] = {\n            type: 'line',\n            xMin: new Date(event.timestamp),\n            xMax: new Date(event.timestamp),\n            borderColor: getEventColor(),\n            borderWidth: 1,\n            borderDash: [5, 5],\n            enter() {\n              hoveredEventIndex.value = index\n            },\n            leave() {\n              hoveredEventIndex.value = null\n            },\n            label: {\n              display: () => hoveredEventIndex.value === index,\n              content: [event.isOngoing ? `Status: ONGOING` : `Status: RESOLVED`, `Unhealthy for ${event.duration}`, `Started at ${new Date(event.timestamp).toLocaleString()}`],\n              backgroundColor: getEventColor(),\n              color: '#ffffff',\n              font: {\n                size: 11\n              },\n              padding: 6,\n              position\n            }\n          }\n          return acc\n        }, {})\n      }\n    },\n    scales: {\n      x: {\n        type: 'time',\n        time: {\n          unit: props.duration === '24h' ? 'hour' : props.duration === '7d' ? 'day' : 'day',\n          displayFormats: {\n            hour: 'MMM d, ha',\n            day: 'MMM d'\n          }\n        },\n        grid: {\n          color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',\n          drawBorder: false\n        },\n        ticks: {\n          color: isDark.value ? '#9ca3af' : '#6b7280',\n          maxRotation: 0,\n          autoSkipPadding: 20\n        }\n      },\n      y: {\n        beginAtZero: true,\n        grid: {\n          color: isDark.value ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',\n          drawBorder: false\n        },\n        ticks: {\n          color: isDark.value ? '#9ca3af' : '#6b7280',\n          callback: (value) => `${value}ms`\n        }\n      }\n    }\n  }\n})\n\nconst fetchData = async () => {\n  loading.value = true\n  error.value = null\n  try {\n    const response = await fetch(`${props.serverUrl}/api/v1/endpoints/${props.endpointKey}/response-times/${props.duration}/history`, {\n      credentials: 'include'\n    })\n    if (response.status === 200) {\n      const data = await response.json()\n      timestamps.value = data.timestamps || []\n      values.value = data.values || []\n    } else {\n      error.value = 'Failed to load chart data'\n      console.error('[ResponseTimeChart] Error:', await response.text())\n    }\n  } catch (err) {\n    error.value = 'Failed to load chart data'\n    console.error('[ResponseTimeChart] Error:', err)\n  } finally {\n    loading.value = false\n  }\n}\n\nwatch(() => props.duration, () => {\n  fetchData()\n})\n\nonMounted(() => {\n  fetchData()\n  const observer = new MutationObserver(() => {\n    isDark.value = document.documentElement.classList.contains('dark')\n  })\n  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })\n  onUnmounted(() => observer.disconnect())\n})\n</script>"
  },
  {
    "path": "web/app/src/components/SearchBar.vue",
    "content": "<template>\n  <div class=\"flex flex-col lg:flex-row gap-3 lg:gap-4 p-3 sm:p-4 bg-card rounded-lg border\">\n    <div class=\"flex-1\">\n      <div class=\"relative\">\n        <Search class=\"absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n        <label for=\"search-input\" class=\"sr-only\">Search endpoints</label>\n        <Input\n          id=\"search-input\"\n          v-model=\"searchQuery\"\n          type=\"text\"\n          placeholder=\"Search endpoints...\"\n          class=\"pl-10 text-sm sm:text-base\"\n          @input=\"$emit('search', searchQuery)\"\n        />\n      </div>\n    </div>\n    <div class=\"flex flex-col sm:flex-row gap-3 sm:gap-4\">\n      <div class=\"flex items-center gap-2 flex-1 sm:flex-initial\">\n        <label class=\"text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap\">Filter by:</label>\n        <Select \n          v-model=\"filterBy\" \n          :options=\"filterOptions\"\n          placeholder=\"None\"\n          class=\"flex-1 sm:w-[140px] md:w-[160px]\"\n          @update:model-value=\"handleFilterChange\"\n        />\n      </div>\n      \n      <div class=\"flex items-center gap-2 flex-1 sm:flex-initial\">\n        <label class=\"text-xs sm:text-sm font-medium text-muted-foreground whitespace-nowrap\">Sort by:</label>\n        <Select \n          v-model=\"sortBy\" \n          :options=\"sortOptions\"\n          placeholder=\"Name\"\n          class=\"flex-1 sm:w-[90px] md:w-[100px]\"\n          @update:model-value=\"handleSortChange\"\n        />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted } from 'vue'\nimport { Search } from 'lucide-vue-next'\nimport { Input } from '@/components/ui/input'\nimport { Select } from '@/components/ui/select'\n\nconst searchQuery = ref('')\nconst filterBy = ref(localStorage.getItem('gatus:filter-by') || (typeof window !== 'undefined' && window.config?.defaultFilterBy) || 'none')\nconst sortBy = ref(localStorage.getItem('gatus:sort-by') || (typeof window !== 'undefined' && window.config?.defaultSortBy) || 'name')\n\nconst filterOptions = [\n  { label: 'None', value: 'none' },\n  { label: 'Failing', value: 'failing' },\n  { label: 'Unstable', value: 'unstable' }\n]\n\nconst sortOptions = [\n  { label: 'Name', value: 'name' },\n  { label: 'Group', value: 'group' },\n  { label: 'Health', value: 'health' }\n]\n\nconst emit = defineEmits(['search', 'update:showOnlyFailing', 'update:showRecentFailures', 'update:groupByGroup', 'update:sortBy', 'initializeCollapsedGroups'])\n\nconst handleFilterChange = (value, store = true) => {\n  filterBy.value = value\n  if (store)\n    localStorage.setItem('gatus:filter-by', value)\n  \n  // Reset all filter states first\n  emit('update:showOnlyFailing', false)\n  emit('update:showRecentFailures', false)\n  \n  // Apply the selected filter\n  if (value === 'failing') {\n    emit('update:showOnlyFailing', true)\n  } else if (value === 'unstable') {\n    emit('update:showRecentFailures', true)\n  }\n}\n\nconst handleSortChange = (value, store = true) => {\n  sortBy.value = value\n  if (store)\n    localStorage.setItem('gatus:sort-by', value)\n\n  emit('update:sortBy', value)\n  emit('update:groupByGroup', value === 'group')\n  \n  // When switching to group view, initialize collapsed groups\n  if (value === 'group') {\n    emit('initializeCollapsedGroups')\n  }\n}\n\nonMounted(() => {\n  // Apply saved or application wide filter/sort state on load but do not store it in localstorage\n  handleFilterChange(filterBy.value, false)\n  handleSortChange(sortBy.value, false)\n})\n</script>"
  },
  {
    "path": "web/app/src/components/SequentialFlowDiagram.vue",
    "content": "<template>\n  <div class=\"space-y-4\">\n    <!-- Timeline header -->\n    <div class=\"flex items-center gap-4\">\n      <div class=\"text-sm font-medium text-muted-foreground\">Start</div>\n      <div class=\"flex-1 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden\">\n        <div \n          class=\"h-full bg-green-500 dark:bg-green-600 rounded-full transition-all duration-300 ease-out\"\n          :style=\"{ width: progressPercentage + '%' }\"\n        ></div>\n      </div>\n      <div class=\"text-sm font-medium text-muted-foreground\">End</div>\n    </div>\n    \n    <!-- Progress stats -->\n    <div class=\"flex items-center justify-between text-xs text-muted-foreground\">\n      <span>{{ completedSteps }}/{{ totalSteps }} steps successful</span>\n      <span v-if=\"totalDuration > 0\">{{ formatDuration(totalDuration) }} total</span>\n    </div>\n    \n    <!-- Flow steps -->\n    <div class=\"space-y-2\">\n      <FlowStep\n        v-for=\"(step, index) in flowSteps\"\n        :key=\"index\"\n        :step=\"step\"\n        :index=\"index\"\n        :is-last=\"index === flowSteps.length - 1\"\n        :previous-step=\"index > 0 ? flowSteps[index - 1] : null\"\n        @step-click=\"$emit('step-selected', step, index)\"\n      />\n    </div>\n    \n    <!-- Legend -->\n    <div class=\"mt-6 pt-4 border-t\">\n      <div class=\"text-sm font-medium text-muted-foreground mb-2\">Status Legend</div>\n      <div class=\"grid grid-cols-2 md:grid-cols-4 gap-3 text-xs\">\n        <div v-if=\"hasSuccessSteps\" class=\"flex items-center gap-2\">\n          <div class=\"w-4 h-4 rounded-full bg-green-500 flex items-center justify-center\">\n            <CheckCircle class=\"w-3 h-3 text-white\" />\n          </div>\n          <span class=\"text-muted-foreground\">Success</span>\n        </div>\n        \n        <div v-if=\"hasFailedSteps\" class=\"flex items-center gap-2\">\n          <div class=\"w-4 h-4 rounded-full bg-red-500 flex items-center justify-center\">\n            <XCircle class=\"w-3 h-3 text-white\" />\n          </div>\n          <span class=\"text-muted-foreground\">Failed</span>\n        </div>\n        \n        <div v-if=\"hasSkippedSteps\" class=\"flex items-center gap-2\">\n          <div class=\"w-4 h-4 rounded-full bg-gray-400 flex items-center justify-center\">\n            <SkipForward class=\"w-3 h-3 text-white\" />\n          </div>\n          <span class=\"text-muted-foreground\">Skipped</span>\n        </div>\n        \n        <div v-if=\"hasAlwaysRunSteps\" class=\"flex items-center gap-2\">\n          <div class=\"w-4 h-4 rounded-full bg-blue-500 border-2 border-blue-200 dark:border-blue-800 flex items-center justify-center\">\n            <RotateCcw class=\"w-3 h-3 text-white\" />\n          </div>\n          <span class=\"text-muted-foreground\">Always Run</span>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { CheckCircle, XCircle, SkipForward, RotateCcw } from 'lucide-vue-next'\nimport FlowStep from './FlowStep.vue'\nimport { formatDuration } from '@/utils/format'\n\nconst props = defineProps({\n  flowSteps: {\n    type: Array,\n    default: () => []\n  },\n  progressPercentage: {\n    type: Number,\n    default: 0\n  },\n  completedSteps: {\n    type: Number,\n    default: 0\n  },\n  totalSteps: {\n    type: Number,\n    default: 0\n  }\n})\n\ndefineEmits(['step-selected'])\n\n// Use props instead of computing locally for consistency\nconst completedSteps = computed(() => props.completedSteps)\nconst totalSteps = computed(() => props.totalSteps)\n\nconst totalDuration = computed(() => {\n  return props.flowSteps.reduce((total, step) => {\n    return total + (step.duration || 0)\n  }, 0)\n})\n\n// Legend visibility computed properties\nconst hasSuccessSteps = computed(() => {\n  return props.flowSteps.some(step => step.status === 'success')\n})\n\nconst hasFailedSteps = computed(() => {\n  return props.flowSteps.some(step => step.status === 'failed')\n})\n\nconst hasSkippedSteps = computed(() => {\n  return props.flowSteps.some(step => step.status === 'skipped')\n})\n\nconst hasAlwaysRunSteps = computed(() => {\n  return props.flowSteps.some(step => step.isAlwaysRun === true)\n})\n\n</script>"
  },
  {
    "path": "web/app/src/components/Settings.vue",
    "content": "<template>\n  <div id=\"settings\" class=\"fixed bottom-4 left-4 z-50\">\n    <div class=\"flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-full shadow-md p-1\">\n      <!-- Refresh Rate -->\n      <button \n        @click=\"showRefreshMenu = !showRefreshMenu\"\n        :aria-label=\"`Refresh interval: ${formatRefreshInterval(refreshIntervalValue)}`\"\n        :aria-expanded=\"showRefreshMenu\"\n        class=\"flex items-center gap-1.5 px-3 py-1.5 rounded-full hover:bg-accent transition-colors relative\"\n      >\n        <RefreshCw class=\"w-3.5 h-3.5 text-muted-foreground\" />\n        <span class=\"text-xs font-medium\">{{ formatRefreshInterval(refreshIntervalValue) }}</span>\n        \n        <!-- Refresh Rate Dropdown -->\n        <div \n          v-if=\"showRefreshMenu\"\n          @click.stop\n          class=\"absolute bottom-full left-0 mb-2 bg-popover border rounded-lg shadow-lg overflow-hidden\"\n        >\n          <button\n            v-for=\"interval in REFRESH_INTERVALS\"\n            :key=\"interval.value\"\n            @click=\"selectRefreshInterval(interval.value)\"\n            :class=\"[\n              'block w-full px-4 py-2 text-xs text-left hover:bg-accent transition-colors',\n              refreshIntervalValue === interval.value && 'bg-accent'\n            ]\"\n          >\n            {{ interval.label }}\n          </button>\n        </div>\n      </button>\n\n      <!-- Divider -->\n      <div class=\"h-5 w-px bg-border/50\" />\n\n      <!-- Theme Toggle -->\n      <button\n        @click=\"toggleDarkMode\"\n        :aria-label=\"darkMode ? 'Switch to light mode' : 'Switch to dark mode'\"\n        class=\"p-1.5 rounded-full hover:bg-accent transition-colors group relative\"\n      >\n        <Sun v-if=\"darkMode\" class=\"h-3.5 w-3.5 transition-all\" />\n        <Moon v-else class=\"h-3.5 w-3.5 transition-all\" />\n        \n        <!-- Tooltip -->\n        <div 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\">\n          {{ darkMode ? 'Light mode' : 'Dark mode' }}\n        </div>\n      </button>\n    </div>\n  </div>\n</template>\n\n\n<script setup>\nimport { ref, onMounted, onUnmounted } from 'vue'\nimport { Sun, Moon, RefreshCw } from 'lucide-vue-next'\n\nconst emit = defineEmits(['refreshData'])\n\n// Constants\nconst REFRESH_INTERVALS = [\n  { value: '10', label: '10s' },\n  { value: '30', label: '30s' },\n  { value: '60', label: '1m' },\n  { value: '120', label: '2m' },\n  { value: '300', label: '5m' },\n  { value: '600', label: '10m' }\n]\nconst DEFAULT_REFRESH_INTERVAL = '300'\nconst THEME_COOKIE_NAME = 'theme'\nconst THEME_COOKIE_MAX_AGE = 31536000 // 1 year\nconst STORAGE_KEYS = {\n  REFRESH_INTERVAL: 'gatus:refresh-interval'\n}\n\n// Helper functions\nfunction wantsDarkMode() {\n  const themeFromCookie = document.cookie.match(new RegExp(`${THEME_COOKIE_NAME}=(dark|light);?`))?.[1]\n  return themeFromCookie === 'dark' || (!themeFromCookie && (window.matchMedia('(prefers-color-scheme: dark)').matches || document.documentElement.classList.contains(\"dark\")))\n}\n\nfunction getStoredRefreshInterval() {\n  const stored = localStorage.getItem(STORAGE_KEYS.REFRESH_INTERVAL)\n  const parsedValue = stored && parseInt(stored)\n  const isValid = parsedValue && parsedValue >= 10 && REFRESH_INTERVALS.some(i => i.value === stored)\n  return isValid ? stored : DEFAULT_REFRESH_INTERVAL\n}\n\n// State\nconst refreshIntervalValue = ref(getStoredRefreshInterval())\nconst darkMode = ref(wantsDarkMode())\nconst showRefreshMenu = ref(false)\nlet refreshIntervalHandler = null\n\n// Methods\nconst formatRefreshInterval = (value) => {\n  const interval = REFRESH_INTERVALS.find(i => i.value === value)\n  return interval ? interval.label : `${value}s`\n}\n\nconst setRefreshInterval = (seconds) => {\n  localStorage.setItem(STORAGE_KEYS.REFRESH_INTERVAL, seconds)\n  if (refreshIntervalHandler) {\n    clearInterval(refreshIntervalHandler)\n  }\n  refreshIntervalHandler = setInterval(() => {\n    refreshData()\n  }, seconds * 1000)\n}\n\nconst refreshData = () => {\n  emit('refreshData')\n}\n\nconst selectRefreshInterval = (value) => {\n  refreshIntervalValue.value = value\n  showRefreshMenu.value = false\n  refreshData()\n  setRefreshInterval(value)\n}\n\n// Close menu when clicking outside\nconst handleClickOutside = (event) => {\n  const settings = document.getElementById('settings')\n  if (settings && !settings.contains(event.target)) {\n    showRefreshMenu.value = false\n  }\n}\n\nconst setThemeCookie = (theme) => {\n  document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; samesite=strict`\n}\n\nconst toggleDarkMode = () => {\n  const newTheme = wantsDarkMode() ? 'light' : 'dark'\n  setThemeCookie(newTheme)\n  applyTheme()\n}\n\nconst applyTheme = () => {\n  const isDark = wantsDarkMode()\n  darkMode.value = isDark\n  document.documentElement.classList.toggle('dark', isDark)\n}\n\n// Lifecycle\nonMounted(() => {\n  setRefreshInterval(refreshIntervalValue.value)\n  applyTheme()\n  document.addEventListener('click', handleClickOutside)\n})\n\nonUnmounted(() => {\n  if (refreshIntervalHandler) {\n    clearInterval(refreshIntervalHandler)\n  }\n  document.removeEventListener('click', handleClickOutside)\n})\n</script>\n\n\n<style scoped>\n/* Animations for smooth transitions */\n@keyframes slideIn {\n  from {\n    transform: translateX(-20px);\n    opacity: 0;\n  }\n  to {\n    transform: translateX(0);\n    opacity: 1;\n  }\n}\n\n#settings {\n  animation: slideIn 0.3s ease-out;\n}\n\n#settings > div {\n  transition: all 0.2s ease;\n}\n\n#settings > div:hover {\n  transform: translateY(-2px);\n  box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);\n}\n</style>\n"
  },
  {
    "path": "web/app/src/components/Social.vue",
    "content": "<template>\n  <div id=\"social\">\n    <a href=\"https://github.com/TwiN/gatus\" target=\"_blank\" title=\"Gatus on GitHub\">\n      <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 16 16\" class=\"hover:scale-110\">\n        <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\"/>\n      </svg>\n    </a>\n  </div>\n</template>\n\n<script setup>\n</script>\n\n<style scoped>\n#social {\n  position: fixed;\n  right: 5px;\n  bottom: 5px;\n  padding: 5px;\n  margin: 0;\n  z-index: 100;\n}\n\n#social img {\n  opacity: 0.3;\n}\n\n#social img:hover {\n  opacity: 1;\n}\n</style>"
  },
  {
    "path": "web/app/src/components/StatusBadge.vue",
    "content": "<template>\n  <Badge :variant=\"variant\" class=\"flex items-center gap-1\">\n    <span :class=\"['w-2 h-2 rounded-full', dotClass]\"></span>\n    {{ label }}\n  </Badge>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { Badge } from '@/components/ui/badge'\n\nconst props = defineProps({\n  status: {\n    type: String,\n    required: true,\n    validator: (value) => ['healthy', 'unhealthy', 'degraded', 'unknown'].includes(value)\n  }\n})\n\nconst variant = computed(() => {\n  switch (props.status) {\n    case 'healthy':\n      return 'success'\n    case 'unhealthy':\n      return 'destructive'\n    case 'degraded':\n      return 'warning'\n    default:\n      return 'secondary'\n  }\n})\n\nconst label = computed(() => {\n  switch (props.status) {\n    case 'healthy':\n      return 'Healthy'\n    case 'unhealthy':\n      return 'Unhealthy'\n    case 'degraded':\n      return 'Degraded'\n    default:\n      return 'Unknown'\n  }\n})\n\nconst dotClass = computed(() => {\n  switch (props.status) {\n    case 'healthy':\n      return 'bg-green-400'\n    case 'unhealthy':\n      return 'bg-red-400'\n    case 'degraded':\n      return 'bg-yellow-400'\n    default:\n      return 'bg-gray-400'\n  }\n})\n</script>"
  },
  {
    "path": "web/app/src/components/StepDetailsModal.vue",
    "content": "<template>\n  <!-- Modal backdrop -->\n  <div class=\"fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50\" @click=\"$emit('close')\">\n    <!-- Modal content -->\n    <div class=\"bg-background border rounded-lg shadow-lg max-w-2xl w-full max-h-[80vh] overflow-hidden\" @click.stop>\n      <!-- Header -->\n      <div class=\"flex items-center justify-between p-4 border-b\">\n        <div>\n          <h2 class=\"text-lg font-semibold flex items-center gap-2\">\n            <component :is=\"statusIcon\" :class=\"iconClasses\" class=\"w-5 h-5\" />\n            {{ step.name }}\n          </h2>\n          <p class=\"text-sm text-muted-foreground mt-1\">\n            Step {{ index + 1 }} • {{ formatDuration(step.duration) }}\n          </p>\n        </div>\n        <Button variant=\"ghost\" size=\"icon\" @click=\"$emit('close')\">\n          <X class=\"w-4 h-4\" />\n        </Button>\n      </div>\n      \n      <!-- Content -->\n      <div class=\"p-4 space-y-4 overflow-y-auto max-h-[60vh]\">\n        <!-- Special properties -->\n        <div v-if=\"step.isAlwaysRun\" class=\"flex flex-wrap gap-2\">\n          <div 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\">\n            <RotateCcw class=\"w-4 h-4 text-blue-600 dark:text-blue-400\" />\n            <div>\n              <p class=\"text-sm font-medium text-blue-900 dark:text-blue-200\">Always Run</p>\n              <p class=\"text-xs text-blue-600 dark:text-blue-400\">This endpoint is configured to execute even after failures</p>\n            </div>\n          </div>\n        </div>\n        \n        <!-- Errors section -->\n        <div v-if=\"step.errors?.length\" class=\"space-y-2\">\n          <h3 class=\"text-sm font-medium flex items-center gap-2 text-red-600 dark:text-red-400\">\n            <AlertCircle class=\"w-4 h-4\" />\n            Errors ({{ step.errors.length }})\n          </h3>\n          <div class=\"space-y-2\">\n            <div v-for=\"(error, index) in step.errors\" :key=\"index\" \n                 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\">\n              {{ error }}\n            </div>\n          </div>\n        </div>\n        \n        <!-- Timestamp -->\n        <div v-if=\"step.result && step.result.timestamp\" class=\"space-y-2\">\n          <h3 class=\"text-sm font-medium flex items-center gap-2\">\n            <Clock class=\"w-4 h-4\" />\n            Timestamp\n          </h3>\n          <p class=\"text-xs font-mono text-muted-foreground\">{{ prettifyTimestamp(step.result.timestamp) }}</p>\n        </div>\n        \n        <!-- Response details -->\n        <div v-if=\"step.result\" class=\"space-y-2\">\n          <h3 class=\"text-sm font-medium flex items-center gap-2\">\n            <Download class=\"w-4 h-4\" />\n            Response\n          </h3>\n          <div class=\"grid grid-cols-2 gap-4 text-xs\">\n            <div>\n              <span class=\"text-muted-foreground\">Duration:</span>\n              <p class=\"font-mono mt-1\">{{ formatDuration(step.result.duration) }}</p>\n            </div>\n            <div>\n              <span class=\"text-muted-foreground\">Success:</span>\n              <p class=\"mt-1\" :class=\"step.result.success ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'\">\n                {{ step.result.success ? 'Yes' : 'No' }}\n              </p>\n            </div>\n          </div>\n        </div>\n\n        <!-- Condition Results -->\n        <div v-if=\"step.result?.conditionResults?.length\" class=\"space-y-2\">\n          <h3 class=\"text-sm font-medium flex items-center gap-2\">\n            <CheckCircle class=\"w-4 h-4\" />\n            Condition Results ({{ step.result.conditionResults.length }})\n          </h3>\n          <div class=\"space-y-2 max-h-48 overflow-y-auto\">\n            <div\n              v-for=\"(conditionResult, index) in step.result.conditionResults\"\n              :key=\"index\"\n              class=\"flex items-start gap-3 p-1 rounded-lg border\"\n              :class=\"conditionResult.success\n                ? 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-700'\n                : 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-700'\"\n            >\n              <!-- Status icon -->\n              <div class=\"flex-shrink-0 mt-0.5\">\n                <CheckCircle\n                  v-if=\"conditionResult.success\"\n                  class=\"w-4 h-4 text-green-600 dark:text-green-400\"\n                />\n                <XCircle\n                  v-else\n                  class=\"w-4 h-4 text-red-600 dark:text-red-400\"\n                />\n              </div>\n\n              <!-- Condition text -->\n              <div class=\"flex-1 min-w-0 flex items-center justify-between gap-3\">\n                <p class=\"text-sm font-mono break-all\"\n                   :class=\"conditionResult.success\n                     ? 'text-green-800 dark:text-green-200'\n                     : 'text-red-800 dark:text-red-200'\">\n                  {{ conditionResult.condition }}\n                </p>\n                <span class=\"text-xs font-medium whitespace-nowrap\"\n                      :class=\"conditionResult.success\n                        ? 'text-green-600 dark:text-green-400'\n                        : 'text-red-600 dark:text-red-400'\">\n                  {{ conditionResult.success ? 'Passed' : 'Failed' }}\n                </span>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Endpoint Configuration -->\n        <div v-if=\"step.endpoint\" class=\"space-y-2\">\n          <h3 class=\"text-sm font-medium flex items-center gap-2\">\n            <Settings class=\"w-4 h-4\" />\n            Endpoint Configuration\n          </h3>\n          <div class=\"space-y-3 text-xs\">\n            <div v-if=\"step.endpoint.url\">\n              <span class=\"text-muted-foreground\">URL:</span>\n              <p class=\"font-mono mt-1 break-all\">{{ step.endpoint.url }}</p>\n            </div>\n            <div v-if=\"step.endpoint.method\">\n              <span class=\"text-muted-foreground\">Method:</span>\n              <p class=\"mt-1 font-medium\">{{ step.endpoint.method }}</p>\n            </div>\n            <div v-if=\"step.endpoint.interval\">\n              <span class=\"text-muted-foreground\">Interval:</span>\n              <p class=\"mt-1\">{{ step.endpoint.interval }}</p>\n            </div>\n            <div v-if=\"step.endpoint.timeout\">\n              <span class=\"text-muted-foreground\">Timeout:</span>\n              <p class=\"mt-1\">{{ step.endpoint.timeout }}</p>\n            </div>\n          </div>\n        </div>\n\n        <!-- Result Errors (separate from step errors) -->\n        <div v-if=\"step.result?.errors?.length\" class=\"space-y-2\">\n          <h3 class=\"text-sm font-medium flex items-center gap-2 text-red-600 dark:text-red-400\">\n            <AlertCircle class=\"w-4 h-4\" />\n            Result Errors ({{ step.result.errors.length }})\n          </h3>\n          <div class=\"space-y-2 max-h-32 overflow-y-auto\">\n            <div v-for=\"(error, index) in step.result.errors\" :key=\"index\"\n                 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\">\n              {{ error }}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { X, AlertCircle, RotateCcw, Download, CheckCircle, XCircle, SkipForward, Pause, Clock, Settings } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { formatDuration } from '@/utils/format'\nimport { prettifyTimestamp } from '@/utils/time'\n\nconst props = defineProps({\n  step: { type: Object, required: true },\n  index: { type: Number, required: true }\n})\n\ndefineEmits(['close'])\n\nconst statusIcon = computed(() => {\n  switch (props.step.status) {\n    case 'success': return CheckCircle\n    case 'failed': return XCircle\n    case 'skipped': return SkipForward\n    case 'not-started': return Pause\n    default: return Pause\n  }\n})\n\nconst iconClasses = computed(() => {\n  switch (props.step.status) {\n    case 'success': return 'text-green-600 dark:text-green-400'\n    case 'failed': return 'text-red-600 dark:text-red-400'\n    case 'skipped': return 'text-gray-600 dark:text-gray-400'\n    default: return 'text-blue-600 dark:text-blue-400'\n  }\n})\n\n</script>"
  },
  {
    "path": "web/app/src/components/SuiteCard.vue",
    "content": "<template>\n  <Card class=\"suite h-full flex flex-col transition hover:shadow-lg hover:scale-[1.01] dark:hover:border-gray-700\">\n    <CardHeader class=\"suite-header px-3 sm:px-6 pt-3 sm:pt-6 pb-2 space-y-0\">\n      <div class=\"flex items-start justify-between gap-2 sm:gap-3\">\n        <div class=\"flex-1 min-w-0 overflow-hidden\">\n          <CardTitle class=\"text-base sm:text-lg truncate\">\n            <span \n              class=\"hover:text-primary cursor-pointer hover:underline text-sm sm:text-base block truncate\" \n              @click=\"navigateToDetails\" \n              @keydown.enter=\"navigateToDetails\"\n              :title=\"suite.name\"\n              role=\"link\"\n              tabindex=\"0\"\n              :aria-label=\"`View details for suite ${suite.name}`\">\n              {{ suite.name }}\n            </span>\n          </CardTitle>\n          <div class=\"flex items-center gap-2 text-xs sm:text-sm text-muted-foreground\">\n            <span v-if=\"suite.group\" class=\"truncate\" :title=\"suite.group\">{{ suite.group }}</span>\n            <span v-if=\"suite.group && endpointCount\">•</span>\n            <span v-if=\"endpointCount\">{{ endpointCount }} endpoint{{ endpointCount !== 1 ? 's' : '' }}</span>\n          </div>\n        </div>\n        <div class=\"flex-shrink-0 ml-2\">\n          <StatusBadge :status=\"currentStatus\" />\n        </div>\n      </div>\n    </CardHeader>\n    <CardContent class=\"suite-content flex-1 pb-3 sm:pb-4 px-3 sm:px-6 pt-2\">\n      <div class=\"space-y-2\">\n        <div>\n          <div class=\"flex items-center justify-between mb-1\">\n            <p class=\"text-xs text-muted-foreground\">Success Rate: {{ successRate }}%</p>\n            <p class=\"text-xs text-muted-foreground\" v-if=\"averageDuration !== null\">{{ averageDuration }}ms avg</p>\n          </div>\n          <div class=\"flex gap-0.5\">\n            <div\n              v-for=\"(result, index) in displayResults\"\n              :key=\"index\"\n              :class=\"[\n                'flex-1 h-6 sm:h-8 rounded-sm transition-all',\n                result ? 'cursor-pointer' : '',\n                result ? (\n                  result.success\n                    ? (selectedResultIndex === index ? 'bg-green-700' : 'bg-green-500 hover:bg-green-700')\n                    : (selectedResultIndex === index ? 'bg-red-700' : 'bg-red-500 hover:bg-red-700')\n                ) : 'bg-gray-200 dark:bg-gray-700'\n              ]\"\n              @mouseenter=\"result && handleMouseEnter(result, $event)\"\n              @mouseleave=\"result && handleMouseLeave(result, $event)\"\n              @click.stop=\"result && handleClick(result, $event, index)\"\n            />\n          </div>\n          <div class=\"flex items-center justify-between text-xs text-muted-foreground mt-1\">\n            <span>{{ oldestResultTime }}</span>\n            <span>{{ newestResultTime }}</span>\n          </div>\n        </div>\n      </div>\n    </CardContent>\n  </Card>\n</template>\n\n<script setup>\nimport { computed, ref, onMounted, onUnmounted } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'\nimport StatusBadge from '@/components/StatusBadge.vue'\nimport { generatePrettyTimeAgo } from '@/utils/time'\n\nconst router = useRouter()\n\nconst props = defineProps({\n  suite: {\n    type: Object,\n    required: true\n  },\n  maxResults: {\n    type: Number,\n    default: 50\n  }\n})\n\nconst emit = defineEmits(['showTooltip'])\n\n// Track selected data point\nconst selectedResultIndex = ref(null)\n\n// Computed properties\nconst displayResults = computed(() => {\n  const results = [...(props.suite.results || [])]\n  while (results.length < props.maxResults) {\n    results.unshift(null)\n  }\n  return results.slice(-props.maxResults)\n})\n\nconst currentStatus = computed(() => {\n  if (!props.suite.results || props.suite.results.length === 0) {\n    return 'unknown'\n  }\n  return props.suite.results[props.suite.results.length - 1].success ? 'healthy' : 'unhealthy'\n})\n\nconst endpointCount = computed(() => {\n  if (!props.suite.results || props.suite.results.length === 0) {\n    return 0\n  }\n  const latestResult = props.suite.results[props.suite.results.length - 1]\n  return latestResult.endpointResults ? latestResult.endpointResults.length : 0\n})\n\nconst successRate = computed(() => {\n  if (!props.suite.results || props.suite.results.length === 0) {\n    return 0\n  }\n  \n  const successful = props.suite.results.filter(r => r.success).length\n  return Math.round((successful / props.suite.results.length) * 100)\n})\n\nconst averageDuration = computed(() => {\n  if (!props.suite.results || props.suite.results.length === 0) {\n    return null\n  }\n  \n  const total = props.suite.results.reduce((sum, r) => sum + (r.duration || 0), 0)\n  // Convert nanoseconds to milliseconds\n  return Math.trunc((total / props.suite.results.length) / 1000000)\n})\n\nconst oldestResultTime = computed(() => {\n  if (!props.suite.results || props.suite.results.length === 0) {\n    return 'N/A'\n  }\n  \n  const oldestResult = props.suite.results[0]\n  return generatePrettyTimeAgo(oldestResult.timestamp)\n})\n\nconst newestResultTime = computed(() => {\n  if (!props.suite.results || props.suite.results.length === 0) {\n    return 'Now'\n  }\n  \n  const newestResult = props.suite.results[props.suite.results.length - 1]\n  return generatePrettyTimeAgo(newestResult.timestamp)\n})\n\n// Methods\nconst navigateToDetails = () => {\n  router.push(`/suites/${props.suite.key}`)\n}\n\nconst handleMouseEnter = (result, event) => {\n  emit('showTooltip', result, event, 'hover')\n}\n\nconst handleMouseLeave = (result, event) => {\n  emit('showTooltip', null, event, 'hover')\n}\n\nconst handleClick = (result, event, index) => {\n  // Clear selections in other cards first\n  window.dispatchEvent(new CustomEvent('clear-data-point-selection'))\n  // Then toggle this card's selection\n  if (selectedResultIndex.value === index) {\n    selectedResultIndex.value = null\n    emit('showTooltip', null, event, 'click')\n  } else {\n    selectedResultIndex.value = index\n    emit('showTooltip', result, event, 'click')\n  }\n}\n\n// Listen for clear selection event\nconst handleClearSelection = () => {\n  selectedResultIndex.value = null\n}\n\nonMounted(() => {\n  window.addEventListener('clear-data-point-selection', handleClearSelection)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('clear-data-point-selection', handleClearSelection)\n})\n</script>\n\n<style scoped>\n.suite {\n  transition: all 0.2s ease;\n}\n\n.suite:hover {\n  transform: translateY(-2px);\n}\n\n.suite-header {\n  border-bottom: 1px solid rgba(0, 0, 0, 0.05);\n}\n\n.dark .suite-header {\n  border-bottom: 1px solid rgba(255, 255, 255, 0.05);\n}\n</style>"
  },
  {
    "path": "web/app/src/components/Tooltip.vue",
    "content": "<template>\n  <div\n    id=\"tooltip\"\n    ref=\"tooltip\"\n    :class=\"[\n      'absolute z-50 px-3 py-2 text-sm rounded-md shadow-lg border transition-all duration-200',\n      'bg-popover text-popover-foreground border-border',\n      hidden ? 'invisible opacity-0' : 'visible opacity-100'\n    ]\"\n    :style=\"`top: ${top}px; left: ${left}px;`\"\n  >\n    <div v-if=\"result\" class=\"space-y-2\">\n      <!-- Status (for suite results) -->\n      <div v-if=\"isSuiteResult\" class=\"flex items-center gap-2\">\n        <span :class=\"[\n          'inline-block w-2 h-2 rounded-full',\n          result.success ? 'bg-green-500' : 'bg-red-500'\n        ]\"></span>\n        <span class=\"text-xs font-semibold\">\n          {{ result.success ? 'Suite Passed' : 'Suite Failed' }}\n        </span>\n      </div>\n\n      <!-- Timestamp -->\n      <div>\n        <div class=\"text-xs font-semibold text-muted-foreground uppercase tracking-wider\">Timestamp</div>\n        <div class=\"font-mono text-xs\">{{ prettifyTimestamp(result.timestamp) }}</div>\n      </div>\n      \n      <!-- Suite Info (for suite results) -->\n      <div v-if=\"isSuiteResult && result.endpointResults\">\n        <div class=\"text-xs font-semibold text-muted-foreground uppercase tracking-wider\">Endpoints</div>\n        <div class=\"font-mono text-xs\">\n          <span :class=\"successCount === endpointCount ? 'text-green-500' : 'text-yellow-500'\">\n            {{ successCount }}/{{ endpointCount }} passed\n          </span>\n        </div>\n        <!-- Endpoint breakdown -->\n        <div v-if=\"result.endpointResults.length > 0\" class=\"mt-1 space-y-0.5\">\n          <div \n            v-for=\"(endpoint, index) in result.endpointResults.slice(0, 5)\" \n            :key=\"index\"\n            class=\"flex items-center gap-1 text-xs\"\n          >\n            <span :class=\"endpoint.success ? 'text-green-500' : 'text-red-500'\">\n              {{ endpoint.success ? '✓' : '✗' }}\n            </span>\n            <span class=\"truncate\">{{ endpoint.name }}</span>\n            <span class=\"text-muted-foreground\">({{ Math.trunc(endpoint.duration / 1000000) }}ms)</span>\n          </div>\n          <div v-if=\"result.endpointResults.length > 5\" class=\"text-xs text-muted-foreground\">\n            ... and {{ result.endpointResults.length - 5 }} more\n          </div>\n        </div>\n      </div>\n\n      <!-- Response Time -->\n      <div>\n        <div class=\"text-xs font-semibold text-muted-foreground uppercase tracking-wider\">\n          {{ isSuiteResult ? 'Total Duration' : 'Response Time' }}\n        </div>\n        <div class=\"font-mono text-xs\">\n          {{ Math.trunc(result.duration / 1000000) }}ms\n        </div>\n      </div>\n      \n      <!-- Conditions (for endpoint results) -->\n      <div v-if=\"!isSuiteResult && result.conditionResults && result.conditionResults.length\">\n        <div class=\"text-xs font-semibold text-muted-foreground uppercase tracking-wider\">Conditions</div>\n        <div class=\"font-mono text-xs space-y-0.5\">\n          <div \n            v-for=\"(conditionResult, index) in result.conditionResults\" \n            :key=\"index\"\n            class=\"flex items-start gap-1\"\n          >\n            <span :class=\"conditionResult.success ? 'text-green-500' : 'text-red-500'\">\n              {{ conditionResult.success ? '✓' : '✗' }}\n            </span>\n            <span class=\"break-all\">{{ conditionResult.condition }}</span>\n          </div>\n        </div>\n      </div>\n      \n      <!-- Errors -->\n      <div v-if=\"result.errors && result.errors.length\">\n        <div class=\"text-xs font-semibold text-muted-foreground uppercase tracking-wider\">Errors</div>\n        <div class=\"font-mono text-xs space-y-0.5\">\n          <div v-for=\"(error, index) in result.errors\" :key=\"index\" class=\"text-red-500\">\n            • {{ error }}\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { prettifyTimestamp } from '@/utils/time'\n\nconst route = useRoute()\n\nconst props = defineProps({\n  event: {\n    type: [Event, Object],\n    default: null\n  },\n  result: {\n    type: Object,\n    default: null\n  },\n  isPersistent: {\n    type: Boolean,\n    default: false\n  }\n})\n\n// State\nconst hidden = ref(true)\nconst top = ref(0)\nconst left = ref(0)\nconst tooltip = ref(null)\nconst targetElement = ref(null)\n\n// Computed properties\nconst isSuiteResult = computed(() => {\n  return props.result && props.result.endpointResults !== undefined\n})\n\nconst endpointCount = computed(() => {\n  if (!isSuiteResult.value || !props.result.endpointResults) return 0\n  return props.result.endpointResults.length\n})\n\nconst successCount = computed(() => {\n  if (!isSuiteResult.value || !props.result.endpointResults) return 0\n  return props.result.endpointResults.filter(e => e.success).length\n})\n\n// Methods are imported from utils/time\n\n// Update tooltip position based on target element's current position\nconst updatePosition = async () => {\n  if (!targetElement.value || !tooltip.value || hidden.value) return\n\n  await nextTick()\n\n  const targetRect = targetElement.value.getBoundingClientRect()\n  const tooltipRect = tooltip.value.getBoundingClientRect()\n\n  // For absolute positioning, we need to add scroll offsets\n  const scrollTop = window.pageYOffset || document.documentElement.scrollTop\n  const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft\n\n  // Default position: below the target (viewport coords + scroll offset)\n  let newTop = targetRect.bottom + scrollTop + 8\n  let newLeft = targetRect.left + scrollLeft\n\n  // Check if tooltip would overflow the viewport bottom\n  const spaceBelow = window.innerHeight - targetRect.bottom\n  const spaceAbove = targetRect.top\n\n  if (spaceBelow < tooltipRect.height + 20) {\n    // Not enough space below, try above\n    if (spaceAbove > tooltipRect.height + 20) {\n      // Position above\n      newTop = targetRect.top + scrollTop - tooltipRect.height - 8\n    } else {\n      // Not enough space above either, position at the best spot\n      if (spaceAbove > spaceBelow) {\n        // More space above\n        newTop = scrollTop + 10\n      } else {\n        // More space below or equal, keep below but adjust\n        newTop = scrollTop + window.innerHeight - tooltipRect.height - 10\n      }\n    }\n  }\n\n  // Adjust horizontal position if tooltip would overflow right edge\n  const spaceRight = window.innerWidth - targetRect.left\n  if (spaceRight < tooltipRect.width + 20) {\n    // Align right edge of tooltip with right edge of target\n    newLeft = targetRect.right + scrollLeft - tooltipRect.width\n    // Make sure it doesn't go off the left edge\n    if (newLeft < scrollLeft + 10) {\n      newLeft = scrollLeft + 10\n    }\n  }\n\n  top.value = Math.round(newTop)\n  left.value = Math.round(newLeft)\n}\n\nconst reposition = async () => {\n  if (!props.event || !props.event.type) return\n\n  await nextTick()\n\n  if ((props.event.type === 'mouseenter' || props.event.type === 'click') && tooltip.value) {\n    const target = props.event.target\n    // Store the target element for scroll updates\n    targetElement.value = target\n\n    // First, make tooltip visible to get its dimensions\n    hidden.value = false\n    await nextTick()\n\n    // Update position\n    await updatePosition()\n  } else if (props.event.type === 'mouseleave') {\n    // Only hide on mouseleave if not in persistent mode\n    if (!props.isPersistent) {\n      hidden.value = true\n      targetElement.value = null\n    }\n  }\n}\n\n// Handle resize events (still needed for viewport size changes)\nconst handleResize = () => {\n  updatePosition()\n}\n\n// Lifecycle hooks\nonMounted(() => {\n  window.addEventListener('resize', handleResize)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize)\n})\n\n// Watchers\nwatch(() => props.event, (newEvent) => {\n  if (newEvent && newEvent.type) {\n    if (newEvent.type === 'mouseenter' || newEvent.type === 'click') {\n      hidden.value = false\n      nextTick(() => reposition())\n    } else if (newEvent.type === 'mouseleave') {\n      // Only hide on mouseleave if not in persistent mode\n      if (!props.isPersistent) {\n        hidden.value = true\n      }\n    }\n  }\n}, { immediate: true })\n\nwatch(() => props.result, () => {\n  if (!hidden.value) {\n    nextTick(() => reposition())\n  }\n})\n\n// Watch for persistent state changes and result changes\nwatch(() => [props.isPersistent, props.result], ([isPersistent, result]) => {\n  if (!isPersistent && !result) {\n    // Hide tooltip when both persistent mode is off and no result\n    hidden.value = true\n  } else if (result && (isPersistent || props.event?.type === 'mouseenter')) {\n    // Show tooltip when there's a result and either persistent or hovering\n    hidden.value = false\n    nextTick(() => reposition())\n  }\n})\n\n// Watch for route changes and hide tooltip\nwatch(() => route.path, () => {\n  hidden.value = true\n  targetElement.value = null\n})\n</script>"
  },
  {
    "path": "web/app/src/components/ui/badge/Badge.vue",
    "content": "<template>\n  <div :class=\"combineClasses(badgeVariants({ variant }), $attrs.class ?? '')\">\n    <slot />\n  </div>\n</template>\n\n<script setup>\nimport { cva } from 'class-variance-authority'\nimport { combineClasses } from '@/utils/misc'\n\ndefineProps({\n  variant: {\n    type: String,\n    default: 'default',\n  },\n})\n\nconst badgeVariants = cva(\n  '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',\n  {\n    variants: {\n      variant: {\n        default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',\n        secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',\n        outline: 'text-foreground',\n        success: 'border-transparent bg-green-500 text-white',\n        warning: 'border-transparent bg-yellow-500 text-white',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  }\n)\n</script>"
  },
  {
    "path": "web/app/src/components/ui/badge/index.js",
    "content": "export { default as Badge } from './Badge.vue'"
  },
  {
    "path": "web/app/src/components/ui/button/Button.vue",
    "content": "<template>\n  <button\n    :class=\"combineClasses(buttonVariants({ variant, size }), $attrs.class ?? '')\"\n    :disabled=\"disabled\"\n  >\n    <slot />\n  </button>\n</template>\n\n<script setup>\nimport { cva } from 'class-variance-authority'\nimport { combineClasses } from '@/utils/misc'\n\ndefineProps({\n  variant: {\n    type: String,\n    default: 'default',\n  },\n  size: {\n    type: String,\n    default: 'default',\n  },\n  disabled: {\n    type: Boolean,\n    default: false,\n  },\n})\n\nconst buttonVariants = cva(\n  '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',\n  {\n    variants: {\n      variant: {\n        default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',\n        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',\n        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        ghost: 'hover:bg-accent hover:text-accent-foreground',\n        link: 'text-primary underline-offset-4 hover:underline',\n      },\n      size: {\n        default: 'h-10 px-4 py-2',\n        sm: 'h-9 rounded-md px-3',\n        lg: 'h-11 rounded-md px-8',\n        icon: 'h-10 w-10',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  }\n)\n</script>"
  },
  {
    "path": "web/app/src/components/ui/button/index.js",
    "content": "export { default as Button } from './Button.vue'"
  },
  {
    "path": "web/app/src/components/ui/card/Card.vue",
    "content": "<template>\n  <div :class=\"combineClasses('rounded-lg border bg-card text-card-foreground shadow-sm', $attrs.class ?? '')\">\n    <slot />\n  </div>\n</template>\n\n<script setup>\nimport { combineClasses } from '@/utils/misc'\n</script>"
  },
  {
    "path": "web/app/src/components/ui/card/CardContent.vue",
    "content": "<template>\n  <div :class=\"combineClasses('p-6 pt-0', $attrs.class ?? '')\">\n    <slot />\n  </div>\n</template>\n\n<script setup>\nimport { combineClasses } from '@/utils/misc'\n</script>"
  },
  {
    "path": "web/app/src/components/ui/card/CardHeader.vue",
    "content": "<template>\n  <div :class=\"combineClasses('flex flex-col space-y-1.5 p-6', $attrs.class ?? '')\">\n    <slot />\n  </div>\n</template>\n\n<script setup>\nimport { combineClasses } from '@/utils/misc'\n</script>"
  },
  {
    "path": "web/app/src/components/ui/card/CardTitle.vue",
    "content": "<template>\n  <h3 :class=\"combineClasses('text-2xl font-semibold leading-none tracking-tight', $attrs.class ?? '')\">\n    <slot />\n  </h3>\n</template>\n\n<script setup>\nimport { combineClasses } from '@/utils/misc'\n</script>"
  },
  {
    "path": "web/app/src/components/ui/card/index.js",
    "content": "export { default as Card } from './Card.vue'\nexport { default as CardHeader } from './CardHeader.vue'\nexport { default as CardTitle } from './CardTitle.vue'\nexport { default as CardContent } from './CardContent.vue'"
  },
  {
    "path": "web/app/src/components/ui/input/Input.vue",
    "content": "<template>\n  <input\n    :class=\"combineClasses(\n      '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',\n      $attrs.class ?? ''\n    )\"\n    :value=\"modelValue\"\n    @input=\"$emit('update:modelValue', $event.target.value)\"\n  />\n</template>\n\n<script setup>\nimport { combineClasses } from '@/utils/misc'\n\ndefineProps({\n  modelValue: {\n    type: [String, Number],\n    default: '',\n  },\n})\n\ndefineEmits(['update:modelValue'])\n</script>"
  },
  {
    "path": "web/app/src/components/ui/input/index.js",
    "content": "export { default as Input } from './Input.vue'"
  },
  {
    "path": "web/app/src/components/ui/select/Select.vue",
    "content": "<template>\n  <div ref=\"selectRef\" class=\"relative\" :class=\"props.class\">\n    <button\n      @click=\"toggleDropdown\"\n      @keydown=\"handleKeyDown\"\n      :aria-expanded=\"isOpen\"\n      :aria-haspopup=\"true\"\n      :aria-label=\"selectedOption.label || props.placeholder\"\n      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\"\n    >\n      <span class=\"truncate\">{{ selectedOption.label }}</span>\n      <ChevronDown class=\"h-3 w-3 sm:h-4 sm:w-4 opacity-50 flex-shrink-0 ml-1\" />\n    </button>\n    \n    <div\n      v-if=\"isOpen\"\n      role=\"listbox\"\n      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\"\n    >\n      <div class=\"p-1\">\n        <div\n          v-for=\"(option, index) in options\"\n          :key=\"option.value\"\n          @click=\"selectOption(option)\"\n          :class=\"[\n            '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',\n            index === focusedIndex && 'bg-accent text-accent-foreground'\n          ]\"\n          role=\"option\"\n          :aria-selected=\"modelValue === option.value\"\n        >\n          <span class=\"absolute left-1.5 sm:left-2 flex h-3.5 w-3.5 items-center justify-center\">\n            <Check v-if=\"modelValue === option.value\" class=\"h-3 w-3 sm:h-4 sm:w-4\" />\n          </span>\n          {{ option.label }}\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onUnmounted } from 'vue'\nimport { ChevronDown, Check } from 'lucide-vue-next'\n\nconst props = defineProps({\n  modelValue: { type: String, default: '' },\n  options: { type: Array, required: true },\n  placeholder: { type: String, default: 'Select...' },\n  class: { type: String, default: '' }\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\nconst isOpen = ref(false)\nconst selectRef = ref(null)\nconst focusedIndex = ref(-1)\n\nconst selectedOption = computed(() => {\n  return props.options.find(option => option.value === props.modelValue) || { label: props.placeholder, value: '' }\n})\n\nconst selectOption = (option) => {\n  emit('update:modelValue', option.value)\n  isOpen.value = false\n}\n\nconst toggleDropdown = () => {\n  isOpen.value = !isOpen.value\n  if (isOpen.value) {\n    // Set initial focus to selected option or first option\n    const selectedIdx = props.options.findIndex(opt => opt.value === props.modelValue)\n    focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0\n  } else {\n    focusedIndex.value = -1\n  }\n}\n\nconst handleClickOutside = (event) => {\n  if (selectRef.value && !selectRef.value.contains(event.target)) {\n    isOpen.value = false\n    focusedIndex.value = -1\n  }\n}\n\nconst handleKeyDown = (event) => {\n  if (!isOpen.value) {\n    if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n      event.preventDefault()\n      toggleDropdown()\n    }\n    return\n  }\n\n  switch (event.key) {\n    case 'ArrowDown':\n      event.preventDefault()\n      focusedIndex.value = Math.min(focusedIndex.value + 1, props.options.length - 1)\n      break\n    case 'ArrowUp':\n      event.preventDefault()\n      focusedIndex.value = Math.max(focusedIndex.value - 1, 0)\n      break\n    case 'Enter':\n    case ' ':\n      event.preventDefault()\n      if (focusedIndex.value >= 0 && focusedIndex.value < props.options.length) {\n        selectOption(props.options[focusedIndex.value])\n      }\n      break\n    case 'Escape':\n      event.preventDefault()\n      isOpen.value = false\n      focusedIndex.value = -1\n      break\n  }\n}\n\nonMounted(() => {\n  document.addEventListener('click', handleClickOutside)\n})\n\nonUnmounted(() => {\n  document.removeEventListener('click', handleClickOutside)\n})\n</script>"
  },
  {
    "path": "web/app/src/components/ui/select/index.js",
    "content": "export { default as Select } from './Select.vue'"
  },
  {
    "path": "web/app/src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --foreground: 222.2 84% 4.9%;\n    --card: 0 0% 100%;\n    --card-foreground: 222.2 84% 4.9%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 222.2 84% 4.9%;\n    --primary: 222.2 47.4% 11.2%;\n    --primary-foreground: 210 40% 98%;\n    --secondary: 210 40% 96.1%;\n    --secondary-foreground: 222.2 47.4% 11.2%;\n    --muted: 210 40% 96.1%;\n    --muted-foreground: 215.4 16.3% 46.9%;\n    --accent: 210 40% 96.1%;\n    --accent-foreground: 222.2 47.4% 11.2%;\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 210 40% 98%;\n    --border: 214.3 31.8% 91.4%;\n    --input: 214.3 31.8% 91.4%;\n    --ring: 222.2 84% 4.9%;\n    --radius: 0.5rem;\n  }\n  \n  :root.dark {\n    --background: 222.2 84% 4.9%;\n    --foreground: 210 40% 98%;\n    --card: 222.2 84% 4.9%;\n    --card-foreground: 210 40% 98%;\n    --popover: 222.2 84% 4.9%;\n    --popover-foreground: 210 40% 98%;\n    --primary: 210 40% 98%;\n    --primary-foreground: 222.2 47.4% 11.2%;\n    --secondary: 217.2 32.6% 17.5%;\n    --secondary-foreground: 210 40% 98%;\n    --muted: 217.2 32.6% 17.5%;\n    --muted-foreground: 215 20.2% 65.1%;\n    --accent: 217.2 32.6% 17.5%;\n    --accent-foreground: 210 40% 98%;\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 210 40% 98%;\n    --border: 217.2 32.6% 17.5%;\n    --input: 217.2 32.6% 17.5%;\n    --ring: 212.7 26.8% 83.9%;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n.bg-success {\n    background-color: #28a745;\n}\n\n\nhtml {\n    height: 100%;\n}\n\nbody {\n    min-height: 100vh;\n}\n\n@media screen and (max-width: 1279px) {\n    body {\n        padding-top: 0;\n        padding-bottom: 0;\n    }\n}\n"
  },
  {
    "path": "web/app/src/main.js",
    "content": "import { createApp } from 'vue'\nimport App from './App.vue'\nimport './index.css'\nimport router from './router'\n\ncreateApp(App).use(router).mount('#app')\n"
  },
  {
    "path": "web/app/src/router/index.js",
    "content": "import {createRouter, createWebHistory} from 'vue-router'\nimport Home from '@/views/Home'\nimport EndpointDetails from \"@/views/EndpointDetails\";\nimport SuiteDetails from '@/views/SuiteDetails';\n\nconst routes = [\n    {\n        path: '/',\n        name: 'Home',\n        component: Home\n    },\n    {\n        path: '/endpoints/:key',\n        name: 'EndpointDetails',\n        component: EndpointDetails,\n    },\n    {\n        path: '/suites/:key',\n        name: 'SuiteDetails',\n        component: SuiteDetails\n    }\n];\n\nconst router = createRouter({\n    history: createWebHistory(process.env.BASE_URL),\n    routes\n});\n\nexport default router;\n"
  },
  {
    "path": "web/app/src/utils/format.js",
    "content": "/**\n * Formats a duration from nanoseconds to a human-readable string\n * @param {number} duration - Duration in nanoseconds\n * @returns {string} Formatted duration string (e.g., \"123ms\", \"1.23s\")\n */\nexport const formatDuration = (duration) => {\n  if (!duration && duration !== 0) return 'N/A'\n  \n  // Convert nanoseconds to milliseconds\n  const durationMs = duration / 1000000\n  \n  if (durationMs < 1000) {\n    return `${Math.trunc(durationMs)}ms`\n  } else {\n    return `${(durationMs / 1000).toFixed(2)}s`\n  }\n}"
  },
  {
    "path": "web/app/src/utils/markdown.js",
    "content": "import { marked } from 'marked'\nimport DOMPurify from 'dompurify'\n\nconst escapeHtml = (value) => {\n  if (value === null || value === undefined) {\n    return ''\n  }\n  return String(value)\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;')\n}\n\nconst renderer = new marked.Renderer()\n\nrenderer.link = (tokenOrHref, title, text) => {\n  const tokenObject = typeof tokenOrHref === 'object' && tokenOrHref !== null\n    ? tokenOrHref\n    : null\n  const href = tokenObject ? tokenObject.href : tokenOrHref\n  const resolvedTitle = tokenObject ? tokenObject.title : title\n  const resolvedText = tokenObject ? tokenObject.text : text\n  const url = escapeHtml(href || '')\n  const titleAttribute = resolvedTitle ? ` title=\"${escapeHtml(resolvedTitle)}\"` : ''\n  const linkText = resolvedText || ''\n  return `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-blue-700 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline font-medium\"${titleAttribute}>${linkText}</a>`\n}\n\nmarked.use({\n  renderer,\n  breaks: true,\n  gfm: true,\n  headerIds: false,\n  mangle: false\n})\n\nexport const formatAnnouncementMessage = (message) => {\n  if (!message) {\n    return ''\n  }\n  const markdown = String(message)\n  const html = marked.parse(markdown)\n  return DOMPurify.sanitize(html, { ADD_ATTR: ['target', 'rel'] })\n}"
  },
  {
    "path": "web/app/src/utils/misc.js",
    "content": "import { clsx } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function combineClasses(...inputs) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "web/app/src/utils/time.js",
    "content": "/**\n * Generates a human-readable relative time string (e.g., \"2 hours ago\")\n * @param {string|Date} timestamp - The timestamp to convert\n * @returns {string} Relative time string\n */\nexport const generatePrettyTimeAgo = (timestamp) => {\n  let differenceInMs = new Date().getTime() - new Date(timestamp).getTime();\n  if (differenceInMs < 500) {\n    return \"now\";\n  }\n  if (differenceInMs > 3 * 86400000) { // If it was more than 3 days ago, we'll display the number of days ago\n    let days = (differenceInMs / 86400000).toFixed(0);\n    return days + \" day\" + (days !== \"1\" ? \"s\" : \"\") + \" ago\";\n  }\n  if (differenceInMs > 3600000) { // If it was more than 1h ago, display the number of hours ago\n    let hours = (differenceInMs / 3600000).toFixed(0);\n    return hours + \" hour\" + (hours !== \"1\" ? \"s\" : \"\") + \" ago\";\n  }\n  if (differenceInMs > 60000) {\n    let minutes = (differenceInMs / 60000).toFixed(0);\n    return minutes + \" minute\" + (minutes !== \"1\" ? \"s\" : \"\") + \" ago\";\n  }\n  let seconds = (differenceInMs / 1000).toFixed(0);\n  return seconds + \" second\" + (seconds !== \"1\" ? \"s\" : \"\") + \" ago\";\n}\n\n/**\n * Generates a pretty time difference string between two timestamps\n * @param {string|Date} start - Start timestamp\n * @param {string|Date} end - End timestamp\n * @returns {string} Time difference string\n */\nexport const generatePrettyTimeDifference = (start, end) => {\n  const ms = new Date(start) - new Date(end)\n  const seconds = Math.floor(ms / 1000)\n  const minutes = Math.floor(seconds / 60)\n  const hours = Math.floor(minutes / 60)\n\n  if (hours > 0) {\n    const remainingMinutes = minutes % 60\n    const hoursText = hours + (hours === 1 ? ' hour' : ' hours')\n    if (remainingMinutes > 0) {\n      return hoursText + ' ' + remainingMinutes + (remainingMinutes === 1 ? ' minute' : ' minutes')\n    }\n    return hoursText\n  } else if (minutes > 0) {\n    const remainingSeconds = seconds % 60\n    const minutesText = minutes + (minutes === 1 ? ' minute' : ' minutes')\n    if (remainingSeconds > 0) {\n      return minutesText + ' ' + remainingSeconds + (remainingSeconds === 1 ? ' second' : ' seconds')\n    }\n    return minutesText\n  } else {\n    return seconds + (seconds === 1 ? ' second' : ' seconds')\n  }\n}\n\n/**\n * Formats a timestamp into YYYY-MM-DD HH:mm:ss format\n * @param {string|Date} timestamp - The timestamp to format\n * @returns {string} Formatted timestamp\n */\nexport const prettifyTimestamp = (timestamp) => {\n  let date = new Date(timestamp);\n  let YYYY = date.getFullYear();\n  let MM = ((date.getMonth() + 1) < 10 ? \"0\" : \"\") + \"\" + (date.getMonth() + 1);\n  let DD = ((date.getDate()) < 10 ? \"0\" : \"\") + \"\" + (date.getDate());\n  let hh = ((date.getHours()) < 10 ? \"0\" : \"\") + \"\" + (date.getHours());\n  let mm = ((date.getMinutes()) < 10 ? \"0\" : \"\") + \"\" + (date.getMinutes());\n  let ss = ((date.getSeconds()) < 10 ? \"0\" : \"\") + \"\" + (date.getSeconds());\n  return YYYY + \"-\" + MM + \"-\" + DD + \" \" + hh + \":\" + mm + \":\" + ss;\n}"
  },
  {
    "path": "web/app/src/views/EndpointDetails.vue",
    "content": "<template>\n  <div class=\"dashboard-container bg-background\">\n    <div class=\"container mx-auto px-4 py-8 max-w-7xl\">\n      <div class=\"mb-6\">\n        <Button variant=\"ghost\" class=\"mb-4\" @click=\"goBack\">\n          <ArrowLeft class=\"h-4 w-4 mr-2\" />\n          Back to Dashboard\n        </Button>\n        \n        <div v-if=\"endpointStatus && endpointStatus.name\" class=\"space-y-6\">\n          <div class=\"flex items-start justify-between\">\n            <div>\n              <h1 class=\"text-4xl font-bold tracking-tight\">{{ endpointStatus.name }}</h1>\n              <div class=\"flex items-center gap-3 text-muted-foreground mt-2\">\n                <span v-if=\"endpointStatus.group\">Group: {{ endpointStatus.group }}</span>\n                <span v-if=\"endpointStatus.group && hostname\">•</span>\n                <span v-if=\"hostname\">{{ hostname }}</span>\n              </div>\n            </div>\n            <StatusBadge :status=\"currentHealthStatus\" />\n          </div>\n\n          <div class=\"grid gap-6 md:grid-cols-2 lg:grid-cols-4\">\n            <Card>\n              <CardHeader class=\"pb-2\">\n                <CardTitle class=\"text-sm font-medium text-muted-foreground\">Current Status</CardTitle>\n              </CardHeader>\n              <CardContent>\n                <div class=\"text-2xl font-bold\">{{ currentHealthStatus === 'healthy' ? 'Operational' : 'Issues Detected' }}</div>\n              </CardContent>\n            </Card>\n\n            <Card>\n              <CardHeader class=\"pb-2\">\n                <CardTitle class=\"text-sm font-medium text-muted-foreground\">Avg Response Time</CardTitle>\n              </CardHeader>\n              <CardContent>\n                <div class=\"text-2xl font-bold\">{{ pageAverageResponseTime }}</div>\n              </CardContent>\n            </Card>\n\n            <Card>\n              <CardHeader class=\"pb-2\">\n                <CardTitle class=\"text-sm font-medium text-muted-foreground\">Response Time Range</CardTitle>\n              </CardHeader>\n              <CardContent>\n                <div class=\"text-2xl font-bold\">{{ pageResponseTimeRange }}</div>\n              </CardContent>\n            </Card>\n\n            <Card>\n              <CardHeader class=\"pb-2\">\n                <CardTitle class=\"text-sm font-medium text-muted-foreground\">Last Check</CardTitle>\n              </CardHeader>\n              <CardContent>\n                <div class=\"text-2xl font-bold\">{{ lastCheckTime }}</div>\n              </CardContent>\n            </Card>\n          </div>\n\n          <Card>\n            <CardHeader>\n              <div class=\"flex items-center justify-between\">\n                <CardTitle>Recent Checks</CardTitle>\n                <div class=\"flex items-center gap-2\">\n                  <Button \n                    variant=\"ghost\" \n                    size=\"icon\"\n                    @click=\"toggleShowAverageResponseTime\"\n                    :title=\"showAverageResponseTime ? 'Show min-max response time' : 'Show average response time'\"\n                  >\n                    <Activity v-if=\"showAverageResponseTime\" class=\"h-5 w-5\" />\n                    <Timer v-else class=\"h-5 w-5\" />\n                  </Button>\n                  <Button \n                    variant=\"ghost\" \n                    size=\"icon\" \n                    @click=\"fetchData\"\n                    title=\"Refresh data\"\n                    :disabled=\"isRefreshing\"\n                  >\n                    <RefreshCw :class=\"['h-4 w-4', isRefreshing && 'animate-spin']\" />\n                  </Button>\n                </div>\n              </div>\n            </CardHeader>\n            <CardContent>\n              <div class=\"space-y-4\">\n                <EndpointCard \n                  v-if=\"endpointStatus\"\n                  :endpoint=\"endpointStatus\"\n                  :maxResults=\"resultPageSize\"\n                  :showAverageResponseTime=\"showAverageResponseTime\"\n                  @showTooltip=\"showTooltip\"\n                  class=\"border-0 shadow-none bg-transparent p-0\"\n                />\n                <div v-if=\"endpointStatus && endpointStatus.key\" class=\"pt-4 border-t\">\n                  <Pagination @page=\"changePage\" :numberOfResultsPerPage=\"resultPageSize\" :currentPageProp=\"currentPage\" />\n                </div>\n              </div>\n            </CardContent>\n          </Card>\n\n          <div v-if=\"showResponseTimeChartAndBadges\" class=\"space-y-6\">\n            <Card>\n              <CardHeader>\n                <div class=\"flex items-center justify-between\">\n                  <CardTitle>Response Time Trend</CardTitle>\n                  <select \n                    v-model=\"selectedChartDuration\"\n                    class=\"text-sm bg-background border rounded-md px-3 py-1 focus:outline-none focus:ring-2 focus:ring-ring\"\n                  >\n                    <option value=\"24h\">24 hours</option>\n                    <option value=\"7d\">7 days</option>\n                    <option value=\"30d\">30 days</option>\n                  </select>\n                </div>\n              </CardHeader>\n              <CardContent>\n                <ResponseTimeChart\n                  v-if=\"endpointStatus && endpointStatus.key\"\n                  :endpointKey=\"endpointStatus.key\"\n                  :duration=\"selectedChartDuration\"\n                  :serverUrl=\"serverUrl\"\n                  :events=\"endpointStatus.events || []\"\n                />\n              </CardContent>\n            </Card>\n\n            <div class=\"grid gap-4 md:grid-cols-2 lg:grid-cols-4\">\n              <Card v-for=\"period in ['30d', '7d', '24h', '1h']\" :key=\"period\">\n                <CardHeader class=\"pb-2\">\n                  <CardTitle class=\"text-sm font-medium text-muted-foreground text-center\">\n                    {{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}\n                  </CardTitle>\n                </CardHeader>\n                <CardContent>\n                  <img :src=\"generateResponseTimeBadgeImageURL(period)\" :alt=\"`${period} response time`\" class=\"mx-auto mt-2\" />\n                </CardContent>\n              </Card>\n            </div>\n          </div>\n\n          <Card>\n            <CardHeader>\n              <CardTitle>Uptime Statistics</CardTitle>\n            </CardHeader>\n            <CardContent>\n              <div class=\"grid gap-4 md:grid-cols-2 lg:grid-cols-4\">\n                <div v-for=\"period in ['30d', '7d', '24h', '1h']\" :key=\"period\" class=\"text-center\">\n                  <p class=\"text-sm text-muted-foreground mb-2\">\n                    {{ period === '30d' ? 'Last 30 days' : period === '7d' ? 'Last 7 days' : period === '24h' ? 'Last 24 hours' : 'Last hour' }}\n                  </p>\n                  <img :src=\"generateUptimeBadgeImageURL(period)\" :alt=\"`${period} uptime`\" class=\"mx-auto\" />\n                </div>\n              </div>\n            </CardContent>\n          </Card>\n\n          <Card>\n            <CardHeader>\n              <CardTitle>Current Health</CardTitle>\n            </CardHeader>\n            <CardContent>\n              <div class=\"text-center\">\n                <img :src=\"generateHealthBadgeImageURL()\" alt=\"health badge\" class=\"mx-auto\" />\n              </div>\n            </CardContent>\n          </Card>\n\n          <Card v-if=\"events && events.length > 0\">\n            <CardHeader>\n              <CardTitle>Events</CardTitle>\n            </CardHeader>\n            <CardContent>\n              <div class=\"space-y-4\">\n                <div v-for=\"event in events\" :key=\"event.timestamp\" class=\"flex items-start gap-4 pb-4 border-b last:border-0\">\n                  <div class=\"mt-1\">\n                    <ArrowUpCircle v-if=\"event.type === 'HEALTHY'\" class=\"h-5 w-5 text-green-500\" />\n                    <ArrowDownCircle v-else-if=\"event.type === 'UNHEALTHY'\" class=\"h-5 w-5 text-red-500\" />\n                    <PlayCircle v-else class=\"h-5 w-5 text-muted-foreground\" />\n                  </div>\n                  <div class=\"flex-1\">\n                    <p class=\"font-medium\">{{ event.fancyText }}</p>\n                    <p class=\"text-sm text-muted-foreground\">{{ prettifyTimestamp(event.timestamp) }} • {{ event.fancyTimeAgo }}</p>\n                  </div>\n                </div>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n\n        <div v-else class=\"flex items-center justify-center py-20\">\n          <Loading size=\"lg\" />\n        </div>\n      </div>\n    </div>\n\n    <Settings @refreshData=\"fetchData\" />\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { useRouter, useRoute } from 'vue-router'\nimport { ArrowLeft, RefreshCw, ArrowUpCircle, ArrowDownCircle, PlayCircle, Activity, Timer } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'\nimport StatusBadge from '@/components/StatusBadge.vue'\nimport EndpointCard from '@/components/EndpointCard.vue'\nimport Settings from '@/components/Settings.vue'\nimport Pagination from '@/components/Pagination.vue'\nimport Loading from '@/components/Loading.vue'\nimport ResponseTimeChart from '@/components/ResponseTimeChart.vue'\nimport { generatePrettyTimeAgo, generatePrettyTimeDifference } from '@/utils/time'\n\nconst router = useRouter()\nconst route = useRoute()\nconst emit = defineEmits(['showTooltip'])\n\nconst endpointStatus = ref(null) // For paginated historical data\nconst currentStatus = ref(null) // For current/latest status (always page 1)\nconst events = ref([])\nconst currentPage = ref(1)\nconst resultPageSize = 50\nconst showResponseTimeChartAndBadges = ref(false)\nconst showAverageResponseTime = ref(localStorage.getItem('gatus:show-average-response-time') !== 'false')\nconst selectedChartDuration = ref('24h')\nconst isRefreshing = ref(false)\n\nconst latestResult = computed(() => {\n  // Use currentStatus for the actual latest result\n  if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {\n    return null\n  }\n  return currentStatus.value.results[currentStatus.value.results.length - 1]\n})\n\nconst currentHealthStatus = computed(() => {\n  if (!latestResult.value) return 'unknown'\n  return latestResult.value.success ? 'healthy' : 'unhealthy'\n})\n\nconst hostname = computed(() => {\n  return latestResult.value?.hostname || null\n})\n\nconst toggleShowAverageResponseTime = () => {\n  showAverageResponseTime.value = !showAverageResponseTime.value\n  localStorage.setItem('gatus:show-average-response-time', showAverageResponseTime.value ? 'true' : 'false')\n}\n\nconst pageAverageResponseTime = computed(() => {\n  // Use endpointStatus for current page's average response time\n  if (!endpointStatus.value || !endpointStatus.value.results || endpointStatus.value.results.length === 0) {\n    return 'N/A'\n  }\n  let total = 0\n  let count = 0\n  for (const result of endpointStatus.value.results) {\n    if (result.duration) {\n      total += result.duration\n      count++\n    }\n  }\n  if (count === 0) return 'N/A'\n  return `${Math.round(total / count / 1000000)}ms`\n})\n\nconst pageResponseTimeRange = computed(() => {\n  // Use endpointStatus for current page's response time range\n  if (!endpointStatus.value || !endpointStatus.value.results || endpointStatus.value.results.length === 0) {\n    return 'N/A'\n  }\n  let min = Infinity\n  let max = 0\n  let hasData = false\n  \n  for (const result of endpointStatus.value.results) {\n    const duration = result.duration\n    if (duration) {\n      min = Math.min(min, duration)\n      max = Math.max(max, duration)\n      hasData = true\n    }\n  }\n  \n  if (!hasData) return 'N/A'\n  const minMs = Math.trunc(min / 1000000)\n  const maxMs = Math.trunc(max / 1000000)\n  // If min and max are the same, show single value\n  if (minMs === maxMs) {\n    return `${minMs}ms`\n  }\n  return `${minMs}-${maxMs}ms`\n})\n\nconst lastCheckTime = computed(() => {\n  // Use currentStatus for real-time last check time\n  if (!currentStatus.value || !currentStatus.value.results || currentStatus.value.results.length === 0) {\n    return 'Never'\n  }\n  return generatePrettyTimeAgo(currentStatus.value.results[currentStatus.value.results.length - 1].timestamp)\n})\n\n\nconst fetchData = async () => {\n  isRefreshing.value = true\n  try {\n    const response = await fetch(`/api/v1/endpoints/${route.params.key}/statuses?page=${currentPage.value}&pageSize=${resultPageSize}`, {\n      credentials: 'include'\n    })\n    \n    if (response.status === 200) {\n      const data = await response.json()\n      endpointStatus.value = data\n      \n      // Always update currentStatus when on page 1 (including when returning to it)\n      if (currentPage.value === 1) {\n        currentStatus.value = data\n      }\n      \n      let processedEvents = []\n      if (data.events && data.events.length > 0) {\n        for (let i = data.events.length - 1; i >= 0; i--) {\n          let event = data.events[i]\n          if (i === data.events.length - 1) {\n            if (event.type === 'UNHEALTHY') {\n              event.fancyText = 'Endpoint is unhealthy'\n            } else if (event.type === 'HEALTHY') {\n              event.fancyText = 'Endpoint is healthy'\n            } else if (event.type === 'START') {\n              event.fancyText = 'Monitoring started'\n            }\n          } else {\n            let nextEvent = data.events[i + 1]\n            if (event.type === 'HEALTHY') {\n              event.fancyText = 'Endpoint became healthy'\n            } else if (event.type === 'UNHEALTHY') {\n              if (nextEvent) {\n                event.fancyText = 'Endpoint was unhealthy for ' + generatePrettyTimeDifference(nextEvent.timestamp, event.timestamp)\n              } else {\n                event.fancyText = 'Endpoint became unhealthy'\n              }\n            } else if (event.type === 'START') {\n              event.fancyText = 'Monitoring started'\n            }\n          }\n          event.fancyTimeAgo = generatePrettyTimeAgo(event.timestamp)\n          processedEvents.push(event)\n        }\n      }\n      events.value = processedEvents\n      \n      if (data.results && data.results.length > 0) {\n        for (let i = 0; i < data.results.length; i++) {\n          if (data.results[i].duration > 0) {\n            showResponseTimeChartAndBadges.value = true\n            break\n          }\n        }\n      }\n    } else {\n      console.error('[Details][fetchData] Error:', await response.text())\n    }\n  } catch (error) {\n    console.error('[Details][fetchData] Error:', error)\n  } finally {\n    isRefreshing.value = false\n  }\n}\n\nconst goBack = () => {\n  router.push('/')\n}\n\nconst changePage = (page) => {\n  currentPage.value = page\n  fetchData()\n}\n\nconst showTooltip = (result, event, action = 'hover') => {\n  emit('showTooltip', result, event, action)\n}\n\nconst prettifyTimestamp = (timestamp) => {\n  return new Date(timestamp).toLocaleString()\n}\n\nconst generateHealthBadgeImageURL = () => {\n  return `/api/v1/endpoints/${endpointStatus.value.key}/health/badge.svg`\n}\n\nconst generateUptimeBadgeImageURL = (duration) => {\n  return `/api/v1/endpoints/${endpointStatus.value.key}/uptimes/${duration}/badge.svg`\n}\n\nconst generateResponseTimeBadgeImageURL = (duration) => {\n  return `/api/v1/endpoints/${endpointStatus.value.key}/response-times/${duration}/badge.svg`\n}\n\nonMounted(() => {\n  fetchData()\n})\n</script>"
  },
  {
    "path": "web/app/src/views/Home.vue",
    "content": "<template>\n  <div class=\"dashboard-container bg-background\">\n    <div class=\"container mx-auto px-4 py-8 max-w-7xl\">\n      <div class=\"mb-6\">\n        <div class=\"flex items-center justify-between mb-6\">\n          <div>\n            <h1 class=\"text-4xl font-bold tracking-tight\">{{ dashboardHeading }}</h1>\n            <p class=\"text-muted-foreground mt-2\">{{ dashboardSubheading }}</p>\n          </div>\n          <div class=\"flex items-center gap-4\">\n            <Button \n              variant=\"ghost\" \n              size=\"icon\" \n              @click=\"toggleShowAverageResponseTime\" \n              :title=\"showAverageResponseTime ? 'Show min-max response time' : 'Show average response time'\"\n            >\n              <Activity v-if=\"showAverageResponseTime\" class=\"h-5 w-5\" />\n              <Timer v-else class=\"h-5 w-5\" />\n            </Button>\n            <Button variant=\"ghost\" size=\"icon\" @click=\"refreshData\" title=\"Refresh data\">\n              <RefreshCw class=\"h-5 w-5\" />\n            </Button>\n          </div>\n        </div>\n        <!-- Announcement Banner (Active Announcements) -->\n        <AnnouncementBanner :announcements=\"activeAnnouncements\" />\n        <!-- Search bar -->\n        <SearchBar\n          @search=\"handleSearch\"\n          @update:showOnlyFailing=\"showOnlyFailing = $event\"\n          @update:showRecentFailures=\"showRecentFailures = $event\"\n          @update:groupByGroup=\"groupByGroup = $event\"\n          @update:sortBy=\"sortBy = $event\"\n          @initializeCollapsedGroups=\"initializeCollapsedGroups\"\n        />\n      </div>\n\n      <div v-if=\"loading\" class=\"flex items-center justify-center py-20\">\n        <Loading size=\"lg\" />\n      </div>\n\n      <div v-else-if=\"filteredEndpoints.length === 0 && filteredSuites.length === 0\" class=\"text-center py-20\">\n        <AlertCircle class=\"h-12 w-12 text-muted-foreground mx-auto mb-4\" />\n        <h3 class=\"text-lg font-semibold mb-2\">No endpoints or suites found</h3>\n        <p class=\"text-muted-foreground\">\n          {{ searchQuery || showOnlyFailing || showRecentFailures \n            ? 'Try adjusting your filters' \n            : 'No endpoints or suites are configured' }}\n        </p>\n      </div>\n\n      <div v-else>\n        <!-- Grouped view -->\n        <div v-if=\"groupByGroup\" class=\"space-y-6\">\n          <div v-for=\"(items, group) in combinedGroups\" :key=\"group\" class=\"endpoint-group border rounded-lg overflow-hidden\">\n            <!-- Group Header -->\n            <div \n              @click=\"toggleGroupCollapse(group)\"\n              class=\"endpoint-group-header flex items-center justify-between p-4 bg-card border-b cursor-pointer hover:bg-accent/50 transition-colors\"\n            >\n              <div class=\"flex items-center gap-3\">\n                <ChevronDown v-if=\"uncollapsedGroups.has(group)\" class=\"h-5 w-5 text-muted-foreground\" />\n                <ChevronUp v-else class=\"h-5 w-5 text-muted-foreground\" />\n                <h2 class=\"text-xl font-semibold text-foreground\">{{ group }}</h2>\n              </div>\n              <div class=\"flex items-center gap-2\">\n                <span v-if=\"calculateUnhealthyCount(items.endpoints) + calculateFailingSuitesCount(items.suites) > 0\" \n                      class=\"bg-red-600 text-white px-2 py-1 rounded-full text-sm font-medium\">\n                  {{ calculateUnhealthyCount(items.endpoints) + calculateFailingSuitesCount(items.suites) }}\n                </span>\n                <CheckCircle v-else class=\"h-6 w-6 text-green-600\" />\n              </div>\n            </div>\n            \n            <!-- Group Content -->\n            <div v-if=\"uncollapsedGroups.has(group)\" class=\"endpoint-group-content p-4\">\n              <!-- Suites Section -->\n              <div v-if=\"items.suites.length > 0\" class=\"mb-4\">\n                <h3 class=\"text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3\">Suites</h3>\n                <div class=\"grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\">\n                  <SuiteCard\n                    v-for=\"suite in items.suites\"\n                    :key=\"suite.key\"\n                    :suite=\"suite\"\n                    :maxResults=\"resultPageSize\"\n                    @showTooltip=\"showTooltip\"\n                  />\n                </div>\n              </div>\n              \n              <!-- Endpoints Section -->\n              <div v-if=\"items.endpoints.length > 0\">\n                <h3 v-if=\"items.suites.length > 0\" class=\"text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3\">Endpoints</h3>\n                <div class=\"grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\">\n                  <EndpointCard\n                    v-for=\"endpoint in items.endpoints\"\n                    :key=\"endpoint.key\"\n                    :endpoint=\"endpoint\"\n                    :maxResults=\"resultPageSize\"\n                    :showAverageResponseTime=\"showAverageResponseTime\"\n                    @showTooltip=\"showTooltip\"\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n        \n        <!-- Regular view -->\n        <div v-else>\n          <!-- Suites Section -->\n          <div v-if=\"filteredSuites.length > 0\" class=\"mb-6\">\n            <h2 class=\"text-lg font-semibold text-foreground mb-3\">Suites</h2>\n            <div class=\"grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\">\n              <SuiteCard\n                v-for=\"suite in paginatedSuites\"\n                :key=\"suite.key\"\n                :suite=\"suite\"\n                :maxResults=\"resultPageSize\"\n                @showTooltip=\"showTooltip\"\n              />\n            </div>\n          </div>\n          \n          <!-- Endpoints Section -->\n          <div v-if=\"filteredEndpoints.length > 0\">\n            <h2 v-if=\"filteredSuites.length > 0\" class=\"text-lg font-semibold text-foreground mb-3\">Endpoints</h2>\n            <div class=\"grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\">\n              <EndpointCard\n                v-for=\"endpoint in paginatedEndpoints\"\n                :key=\"endpoint.key\"\n                :endpoint=\"endpoint\"\n                :maxResults=\"resultPageSize\"\n                :showAverageResponseTime=\"showAverageResponseTime\"\n                @showTooltip=\"showTooltip\"\n              />\n            </div>\n          </div>\n        </div>\n\n        <div v-if=\"!groupByGroup && totalPages > 1\" class=\"mt-8 flex items-center justify-center gap-2\">\n          <Button\n            variant=\"outline\"\n            size=\"icon\"\n            :disabled=\"currentPage === 1\"\n            @click=\"goToPage(currentPage - 1)\"\n          >\n            <ChevronLeft class=\"h-4 w-4\" />\n          </Button>\n          \n          <div class=\"flex gap-1\">\n            <Button\n              v-for=\"page in visiblePages\"\n              :key=\"page\"\n              :variant=\"page === currentPage ? 'default' : 'outline'\"\n              size=\"sm\"\n              @click=\"goToPage(page)\"\n            >\n              {{ page }}\n            </Button>\n          </div>\n\n          <Button\n            variant=\"outline\"\n            size=\"icon\"\n            :disabled=\"currentPage === totalPages\"\n            @click=\"goToPage(currentPage + 1)\"\n          >\n            <ChevronRight class=\"h-4 w-4\" />\n          </Button>\n        </div>\n      </div>\n\n      <!-- Past Announcements Section -->\n      <div v-if=\"archivedAnnouncements.length > 0\" class=\"mt-12 pb-8\">\n        <PastAnnouncements :announcements=\"archivedAnnouncements\" />\n      </div>\n    </div>\n\n    <Settings @refreshData=\"fetchData\" />\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { Activity, Timer, RefreshCw, AlertCircle, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, CheckCircle } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport EndpointCard from '@/components/EndpointCard.vue'\nimport SuiteCard from '@/components/SuiteCard.vue'\nimport SearchBar from '@/components/SearchBar.vue'\nimport Settings from '@/components/Settings.vue'\nimport Loading from '@/components/Loading.vue'\nimport AnnouncementBanner from '@/components/AnnouncementBanner.vue'\nimport PastAnnouncements from '@/components/PastAnnouncements.vue'\n\nconst props = defineProps({\n  announcements: {\n    type: Array,\n    default: () => []\n  }\n})\n\n// Computed properties for active and archived announcements\nconst activeAnnouncements = computed(() => {\n  return props.announcements ? props.announcements.filter(a => !a.archived) : []\n})\n\nconst archivedAnnouncements = computed(() => {\n  return props.announcements ? props.announcements.filter(a => a.archived) : []\n})\n\nconst emit = defineEmits(['showTooltip'])\n\nconst endpointStatuses = ref([])\nconst suiteStatuses = ref([])\nconst loading = ref(false)\nconst currentPage = ref(1)\nconst itemsPerPage = 96\nconst searchQuery = ref('')\nconst showOnlyFailing = ref(false)\nconst showRecentFailures = ref(false)\nconst showAverageResponseTime = ref(localStorage.getItem('gatus:show-average-response-time') !== 'false')\nconst groupByGroup = ref(false)\nconst sortBy = ref(localStorage.getItem('gatus:sort-by') || 'name')\nconst uncollapsedGroups = ref(new Set())\nconst resultPageSize = 50\n\nconst filteredEndpoints = computed(() => {\n  let filtered = [...endpointStatuses.value]\n  \n  if (searchQuery.value) {\n    const query = searchQuery.value.toLowerCase()\n    filtered = filtered.filter(endpoint => \n      endpoint.name.toLowerCase().includes(query) ||\n      (endpoint.group && endpoint.group.toLowerCase().includes(query))\n    )\n  }\n  \n  if (showOnlyFailing.value) {\n    filtered = filtered.filter(endpoint => {\n      if (!endpoint.results || endpoint.results.length === 0) return false\n      const latestResult = endpoint.results[endpoint.results.length - 1]\n      return !latestResult.success\n    })\n  }\n  \n  if (showRecentFailures.value) {\n    filtered = filtered.filter(endpoint => {\n      if (!endpoint.results || endpoint.results.length === 0) return false\n      return endpoint.results.some(result => !result.success)\n    })\n  }\n  \n  // Sort by health if selected\n  if (sortBy.value === 'health') {\n    filtered.sort((a, b) => {\n      const aHealthy = a.results && a.results.length > 0 && a.results[a.results.length - 1].success\n      const bHealthy = b.results && b.results.length > 0 && b.results[b.results.length - 1].success\n      \n      // Unhealthy first\n      if (!aHealthy && bHealthy) return -1\n      if (aHealthy && !bHealthy) return 1\n      \n      // Then sort by name\n      return a.name.localeCompare(b.name)\n    })\n  }\n  \n  return filtered\n})\n\nconst filteredSuites = computed(() => {\n  let filtered = [...(suiteStatuses.value || [])]\n  \n  if (searchQuery.value) {\n    const query = searchQuery.value.toLowerCase()\n    filtered = filtered.filter(suite => \n      suite.name.toLowerCase().includes(query) ||\n      (suite.group && suite.group.toLowerCase().includes(query))\n    )\n  }\n  \n  if (showOnlyFailing.value) {\n    filtered = filtered.filter(suite => {\n      if (!suite.results || suite.results.length === 0) return false\n      return !suite.results[suite.results.length - 1].success\n    })\n  }\n  \n  if (showRecentFailures.value) {\n    filtered = filtered.filter(suite => {\n      if (!suite.results || suite.results.length === 0) return false\n      return suite.results.some(result => !result.success)\n    })\n  }\n  \n  // Sort by health if selected\n  if (sortBy.value === 'health') {\n    filtered.sort((a, b) => {\n      const aHealthy = a.results && a.results.length > 0 && a.results[a.results.length - 1].success\n      const bHealthy = b.results && b.results.length > 0 && b.results[b.results.length - 1].success\n      \n      // Unhealthy first\n      if (!aHealthy && bHealthy) return -1\n      if (aHealthy && !bHealthy) return 1\n      \n      // Then sort by name\n      return a.name.localeCompare(b.name)\n    })\n  }\n  \n  return filtered\n})\n\nconst totalPages = computed(() => {\n  return Math.ceil((filteredEndpoints.value.length + filteredSuites.value.length) / itemsPerPage)\n})\n\nconst groupedEndpoints = computed(() => {\n  if (!groupByGroup.value) {\n    return null\n  }\n  \n  const grouped = {}\n  filteredEndpoints.value.forEach(endpoint => {\n    const group = endpoint.group || 'No Group'\n    if (!grouped[group]) {\n      grouped[group] = []\n    }\n    grouped[group].push(endpoint)\n  })\n  \n  // Sort groups alphabetically, with 'No Group' at the end\n  const sortedGroups = Object.keys(grouped).sort((a, b) => {\n    if (a === 'No Group') return 1\n    if (b === 'No Group') return -1\n    return a.localeCompare(b)\n  })\n  \n  const result = {}\n  sortedGroups.forEach(group => {\n    result[group] = grouped[group]\n  })\n  \n  return result\n})\n\nconst combinedGroups = computed(() => {\n  if (!groupByGroup.value) {\n    return null\n  }\n  \n  const combined = {}\n  \n  // Add endpoints\n  filteredEndpoints.value.forEach(endpoint => {\n    const group = endpoint.group || 'No Group'\n    if (!combined[group]) {\n      combined[group] = { endpoints: [], suites: [] }\n    }\n    combined[group].endpoints.push(endpoint)\n  })\n  \n  // Add suites\n  filteredSuites.value.forEach(suite => {\n    const group = suite.group || 'No Group'\n    if (!combined[group]) {\n      combined[group] = { endpoints: [], suites: [] }\n    }\n    combined[group].suites.push(suite)\n  })\n  \n  // Sort groups alphabetically, with 'No Group' at the end\n  const sortedGroups = Object.keys(combined).sort((a, b) => {\n    if (a === 'No Group') return 1\n    if (b === 'No Group') return -1\n    return a.localeCompare(b)\n  })\n  \n  const result = {}\n  sortedGroups.forEach(group => {\n    result[group] = combined[group]\n  })\n  \n  return result\n})\n\nconst paginatedEndpoints = computed(() => {\n  if (groupByGroup.value) {\n    // When grouping, we don't paginate\n    return groupedEndpoints.value\n  }\n  \n  const start = (currentPage.value - 1) * itemsPerPage\n  const end = start + itemsPerPage\n  return filteredEndpoints.value.slice(start, end)\n})\n\nconst paginatedSuites = computed(() => {\n  if (groupByGroup.value) {\n    // When grouping, we don't paginate\n    return filteredSuites.value\n  }\n  \n  const start = (currentPage.value - 1) * itemsPerPage\n  const end = start + itemsPerPage\n  return filteredSuites.value.slice(start, end)\n})\n\nconst visiblePages = computed(() => {\n  const pages = []\n  const maxVisible = 5\n  let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))\n  let end = Math.min(totalPages.value, start + maxVisible - 1)\n  \n  if (end - start < maxVisible - 1) {\n    start = Math.max(1, end - maxVisible + 1)\n  }\n  \n  for (let i = start; i <= end; i++) {\n    pages.push(i)\n  }\n  \n  return pages\n})\n\nconst fetchData = async () => {\n  // Don't show loading state on refresh to prevent UI flicker\n  const isInitialLoad = endpointStatuses.value.length === 0 && suiteStatuses.value.length === 0\n  if (isInitialLoad) {\n    loading.value = true\n  }\n  try {\n    // Fetch endpoints\n    const endpointResponse = await fetch(`/api/v1/endpoints/statuses?page=1&pageSize=${resultPageSize}`, {\n      credentials: 'include'\n    })\n    if (endpointResponse.status === 200) {\n      const data = await endpointResponse.json()\n      endpointStatuses.value = data\n    } else {\n      console.error('[Home][fetchData] Error fetching endpoints:', await endpointResponse.text())\n    }\n    \n    // Fetch suites\n    const suiteResponse = await fetch(`/api/v1/suites/statuses?page=1&pageSize=${resultPageSize}`, {\n      credentials: 'include'\n    })\n    if (suiteResponse.status === 200) {\n      const suiteData = await suiteResponse.json()\n      suiteStatuses.value = suiteData || []\n    } else {\n      console.error('[Home][fetchData] Error fetching suites:', await suiteResponse.text())\n      // Ensure suiteStatuses stays as empty array instead of becoming null/undefined\n      if (!suiteStatuses.value) {\n        suiteStatuses.value = []\n      }\n    }\n  } catch (error) {\n    console.error('[Home][fetchData] Error:', error)\n  } finally {\n    if (isInitialLoad) {\n      loading.value = false\n    }\n  }\n}\n\nconst refreshData = () => {\n  endpointStatuses.value = [];\n  suiteStatuses.value = [];\n  fetchData()\n}\n\nconst handleSearch = (query) => {\n  searchQuery.value = query\n  currentPage.value = 1\n}\n\nconst goToPage = (page) => {\n  currentPage.value = page\n  window.scrollTo({ top: 0, behavior: 'smooth' })\n}\n\nconst toggleShowAverageResponseTime = () => {\n  showAverageResponseTime.value = !showAverageResponseTime.value\n  localStorage.setItem('gatus:show-average-response-time', showAverageResponseTime.value ? 'true' : 'false')\n}\n\nconst showTooltip = (result, event, action = 'hover') => {\n  emit('showTooltip', result, event, action)\n}\n\nconst calculateUnhealthyCount = (endpoints) => {\n  return endpoints.filter(endpoint => {\n    if (!endpoint.results || endpoint.results.length === 0) return false\n    const latestResult = endpoint.results[endpoint.results.length - 1]\n    return !latestResult.success\n  }).length\n}\n\nconst calculateFailingSuitesCount = (suites) => {\n  return suites.filter(suite => {\n    if (!suite.results || suite.results.length === 0) return false\n    return !suite.results[suite.results.length - 1].success\n  }).length\n}\n\nconst toggleGroupCollapse = (groupName) => {\n  if (uncollapsedGroups.value.has(groupName)) {\n    uncollapsedGroups.value.delete(groupName)\n  } else {\n    uncollapsedGroups.value.add(groupName)\n  }\n  // Save to localStorage\n  const uncollapsed = Array.from(uncollapsedGroups.value)\n  localStorage.setItem('gatus:uncollapsed-groups', JSON.stringify(uncollapsed))\n  localStorage.removeItem('gatus:collapsed-groups') // Remove old key if it exists\n}\n\nconst initializeCollapsedGroups = () => {\n  // Get saved uncollapsed groups from localStorage\n  try {\n    const saved = localStorage.getItem('gatus:uncollapsed-groups')\n    if (saved) {\n      uncollapsedGroups.value = new Set(JSON.parse(saved))\n    }\n    // If no saved state, uncollapsedGroups stays empty (all collapsed by default)\n  } catch (e) {\n    console.warn('Failed to parse saved uncollapsed groups:', e)\n    localStorage.removeItem('gatus:uncollapsed-groups')\n    // On error, uncollapsedGroups stays empty (all collapsed by default)\n  }\n}\n\nconst dashboardHeading = computed(() => {\n  return window.config && window.config.dashboardHeading && window.config.dashboardHeading !== '{{ .UI.DashboardHeading }}' ? window.config.dashboardHeading : \"Health Dashboard\"\n})\n\nconst dashboardSubheading = computed(() => {\n  return window.config && window.config.dashboardSubheading && window.config.dashboardSubheading !== '{{ .UI.DashboardSubheading }}' ? window.config.dashboardSubheading : \"Monitor the health of your endpoints in real-time\"\n})\n\nonMounted(() => {\n  fetchData()\n})\n</script>"
  },
  {
    "path": "web/app/src/views/SuiteDetails.vue",
    "content": "<template>\n  <div class=\"suite-details-container bg-background min-h-screen\">\n    <div class=\"container mx-auto px-4 py-8 max-w-7xl\">\n      <!-- Back button and header -->\n      <div class=\"mb-6\">\n        <Button variant=\"ghost\" size=\"sm\" @click=\"goBack\" class=\"mb-4\">\n          <ArrowLeft class=\"h-4 w-4 mr-2\" />\n          Back to Dashboard\n        </Button>\n        \n        <div class=\"flex items-start justify-between\">\n          <div>\n            <h1 class=\"text-3xl font-bold tracking-tight\">{{ suite?.name || 'Loading...' }}</h1>\n            <p class=\"text-muted-foreground mt-2\">\n              <span v-if=\"suite?.group\">{{ suite.group }} • </span>\n              <span v-if=\"latestResult\">\n                {{ selectedResult && selectedResult.timestamp !== sortedResults[0]?.timestamp ? 'Ran' : 'Last run' }} {{ formatRelativeTime(latestResult.timestamp) }}\n              </span>\n            </p>\n          </div>\n          <div class=\"flex items-center gap-2\">\n            <StatusBadge v-if=\"latestResult\" :status=\"latestResult.success ? 'healthy' : 'unhealthy'\" />\n            <Button variant=\"ghost\" size=\"icon\" @click=\"refreshData\" title=\"Refresh\">\n              <RefreshCw class=\"h-5 w-5\" />\n            </Button>\n          </div>\n        </div>\n      </div>\n\n      <div v-if=\"loading\" class=\"flex items-center justify-center py-20\">\n        <Loading size=\"lg\" />\n      </div>\n\n      <div v-else-if=\"!suite\" class=\"text-center py-20\">\n        <AlertCircle class=\"h-12 w-12 text-muted-foreground mx-auto mb-4\" />\n        <h3 class=\"text-lg font-semibold mb-2\">Suite not found</h3>\n        <p class=\"text-muted-foreground\">The requested suite could not be found.</p>\n      </div>\n\n      <div v-else class=\"space-y-6\">\n        <!-- Latest Execution -->\n        <Card v-if=\"latestResult\">\n          <CardHeader>\n            <CardTitle>{{ selectedResult?.timestamp === sortedResults[0]?.timestamp ? 'Latest Execution' : `Execution at ${formatTimestamp(selectedResult.timestamp)}` }}</CardTitle>\n          </CardHeader>\n          <CardContent>\n            <div class=\"space-y-4\">\n              <!-- Execution stats -->\n              <div class=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n                <div>\n                  <p class=\"text-sm text-muted-foreground\">Status</p>\n                  <p class=\"text-lg font-medium\">{{ latestResult.success ? 'Success' : 'Failed' }}</p>\n                </div>\n                <div>\n                  <p class=\"text-sm text-muted-foreground\">Duration</p>\n                  <p class=\"text-lg font-medium\">{{ formatDuration(latestResult.duration) }}</p>\n                </div>\n                <div>\n                  <p class=\"text-sm text-muted-foreground\">Endpoints</p>\n                  <p class=\"text-lg font-medium\">{{ latestResult.endpointResults?.length || 0 }}</p>\n                </div>\n                <div>\n                  <p class=\"text-sm text-muted-foreground\">Success Rate</p>\n                  <p class=\"text-lg font-medium\">{{ calculateSuccessRate(latestResult) }}%</p>\n                </div>\n              </div>\n\n              <!-- Enhanced Execution Flow -->\n              <div class=\"mt-6\">\n                <h3 class=\"text-lg font-semibold mb-4\">Execution Flow</h3>\n                <SequentialFlowDiagram\n                  :flow-steps=\"flowSteps\"\n                  :progress-percentage=\"executionProgress\"\n                  :completed-steps=\"completedStepsCount\"\n                  :total-steps=\"flowSteps.length\"\n                  @step-selected=\"onStepSelected\"\n                />\n              </div>\n\n\n              <!-- Errors -->\n              <div v-if=\"latestResult.errors && latestResult.errors.length > 0\" class=\"mt-6\">\n                <h3 class=\"text-lg font-semibold mb-3 text-red-500\">Suite Errors</h3>\n                <div class=\"space-y-2\">\n                  <div\n                    v-for=\"(error, index) in latestResult.errors\"\n                    :key=\"index\"\n                    class=\"bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300 p-3 rounded-md text-sm\"\n                  >\n                    {{ error }}\n                  </div>\n                </div>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n\n        <!-- Execution History -->\n        <Card>\n          <CardHeader>\n            <CardTitle>Execution History</CardTitle>\n          </CardHeader>\n          <CardContent>\n            <div v-if=\"sortedResults.length > 0\" class=\"space-y-2\">\n              <div\n                v-for=\"(result, index) in sortedResults\"\n                :key=\"index\"\n                class=\"flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer\"\n                @click=\"selectedResult = result\"\n                :class=\"{ 'bg-accent': selectedResult && selectedResult.timestamp === result.timestamp }\"\n              >\n                <div class=\"flex items-center gap-3\">\n                  <StatusBadge :status=\"result.success ? 'healthy' : 'unhealthy'\" size=\"sm\" />\n                  <div>\n                    <p class=\"text-sm font-medium\">{{ formatTimestamp(result.timestamp) }}</p>\n                    <p class=\"text-xs text-muted-foreground\">\n                      {{ result.endpointResults?.length || 0 }} endpoints • {{ formatDuration(result.duration) }}\n                    </p>\n                  </div>\n                </div>\n                <ChevronRight class=\"h-4 w-4 text-muted-foreground\" />\n              </div>\n            </div>\n            <div v-else class=\"text-center py-8 text-muted-foreground\">\n              No execution history available\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n    </div>\n\n    <Settings @refreshData=\"fetchData\" />\n    \n    <!-- Step Details Modal -->\n    <StepDetailsModal\n      v-if=\"selectedStep\"\n      :step=\"selectedStep\"\n      :index=\"selectedStepIndex\"\n      @close=\"selectedStep = null\"\n    />\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { useRouter, useRoute } from 'vue-router'\nimport { ArrowLeft, RefreshCw, AlertCircle, ChevronRight } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'\nimport StatusBadge from '@/components/StatusBadge.vue'\nimport SequentialFlowDiagram from '@/components/SequentialFlowDiagram.vue'\nimport StepDetailsModal from '@/components/StepDetailsModal.vue'\nimport Settings from '@/components/Settings.vue'\nimport Loading from '@/components/Loading.vue'\nimport { generatePrettyTimeAgo } from '@/utils/time'\nimport { formatDuration } from '@/utils/format'\n\nconst router = useRouter()\nconst route = useRoute()\n\n// State\nconst loading = ref(false)\nconst suite = ref(null)\nconst selectedResult = ref(null)\nconst selectedStep = ref(null)\nconst selectedStepIndex = ref(0)\n\n// Computed properties\nconst sortedResults = computed(() => {\n  if (!suite.value || !suite.value.results || suite.value.results.length === 0) {\n    return []\n  }\n  // Sort results by timestamp in descending order (most recent first)\n  return [...suite.value.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))\n})\n\nconst latestResult = computed(() => {\n  if (!suite.value || !suite.value.results || suite.value.results.length === 0) {\n    return null\n  }\n  return selectedResult.value || sortedResults.value[0]\n})\n\n// Methods\nconst fetchData = async () => {\n  // Don't show loading state on refresh to prevent UI flicker\n  const isInitialLoad = !suite.value\n  if (isInitialLoad) {\n    loading.value = true\n  }\n\n  try {\n    const response = await fetch(`/api/v1/suites/${route.params.key}/statuses`, {\n      credentials: 'include'\n    })\n\n    if (response.status === 200) {\n      const data = await response.json()\n      const oldSuite = suite.value\n      suite.value = data\n      if (data.results && data.results.length > 0) {\n        // Sort results by timestamp to get the most recent one\n        const sorted = [...data.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))\n        // Update selectedResult if: no result selected, or currently viewing the latest result\n        const wasViewingLatest = !selectedResult.value ||\n          (oldSuite?.results && selectedResult.value.timestamp === [...oldSuite.results].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]?.timestamp)\n        if (wasViewingLatest) {\n          selectedResult.value = sorted[0]\n        }\n      }\n    } else if (response.status === 404) {\n      suite.value = null\n    } else {\n      console.error('[SuiteDetails][fetchData] Error:', await response.text())\n    }\n  } catch (error) {\n    console.error('[SuiteDetails][fetchData] Error:', error)\n  } finally {\n    if (isInitialLoad) {\n      loading.value = false\n    }\n  }\n}\n\nconst refreshData = () => {\n  fetchData()\n}\n\nconst goBack = () => {\n  router.push('/')\n}\n\nconst formatRelativeTime = (timestamp) => {\n  return generatePrettyTimeAgo(timestamp)\n}\n\nconst formatTimestamp = (timestamp) => {\n  const date = new Date(timestamp)\n  return date.toLocaleString()\n}\n\nconst calculateSuccessRate = (result) => {\n  if (!result || !result.endpointResults || result.endpointResults.length === 0) {\n    return 0\n  }\n  \n  const successful = result.endpointResults.filter(e => e.success).length\n  return Math.round((successful / result.endpointResults.length) * 100)\n}\n\n// Flow diagram computed properties\nconst flowSteps = computed(() => {\n  if (!latestResult.value || !latestResult.value.endpointResults) {\n    return []\n  }\n  const results = latestResult.value.endpointResults\n  return results.map((result, index) => {\n    const endpoint = suite.value?.endpoints?.[index]\n    const nextResult = results[index + 1]\n    // Determine if this is an always-run endpoint by checking execution pattern\n    // If a previous step failed but this one still executed, it must be always-run\n    let isAlwaysRun = false\n    for (let i = 0; i < index; i++) {\n      if (!results[i].success) {\n        // A previous step failed, but we're still executing, so this must be always-run\n        isAlwaysRun = true\n        break\n      }\n    }\n    return {\n      name: endpoint?.name || result.name || `Step ${index + 1}`,\n      endpoint: endpoint,\n      result: result,\n      status: determineStepStatus(result, endpoint),\n      duration: result.duration || 0,\n      isAlwaysRun: isAlwaysRun,\n      errors: result.errors || [],\n      nextStepStatus: nextResult ? determineStepStatus(nextResult, suite.value?.endpoints?.[index + 1]) : null\n    }\n  })\n})\n\nconst completedStepsCount = computed(() => {\n  return flowSteps.value.filter(step => step.status === 'success').length\n})\n\nconst executionProgress = computed(() => {\n  if (!flowSteps.value.length) return 0\n  return Math.round((completedStepsCount.value / flowSteps.value.length) * 100)\n})\n\n\n// Helper functions\nconst determineStepStatus = (result) => {\n  if (!result) return 'not-started'\n  // Check if step was skipped\n  if (result.conditionResults && result.conditionResults.some(c => c.condition.includes('SKIP'))) {\n    return 'skipped'\n  }\n  // Check if step failed but is always-run (still shows as failed but executed)\n  if (!result.success) {\n    return 'failed'\n  }\n  return 'success'\n}\n\n\n// Event handlers\nconst onStepSelected = (step, index) => {\n  selectedStep.value = step\n  selectedStepIndex.value = index\n}\n\n// Lifecycle\nonMounted(() => {\n  fetchData()\n})\n</script>\n\n<style scoped>\n.suite-details-container {\n  min-height: 100vh;\n}\n</style>"
  },
  {
    "path": "web/app/tailwind.config.js",
    "content": "module.exports = {\n  content: [\n    './public/index.html',\n    './src/**/*.{vue,js,ts,jsx,tsx}'\n  ],\n  darkMode: 'class', // or 'media' or 'class'\n  theme: {\n    fontFamily: {\n      'mono': ['Consolas', 'Monaco', '\"Courier New\"', 'monospace'],\n      'sans': ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif']\n    },\n    extend: {\n      colors: {\n        border: 'hsl(var(--border))',\n        input: 'hsl(var(--input))',\n        ring: 'hsl(var(--ring))',\n        background: 'hsl(var(--background))',\n        foreground: 'hsl(var(--foreground))',\n        primary: {\n          DEFAULT: 'hsl(var(--primary))',\n          foreground: 'hsl(var(--primary-foreground))',\n        },\n        secondary: {\n          DEFAULT: 'hsl(var(--secondary))',\n          foreground: 'hsl(var(--secondary-foreground))',\n        },\n        destructive: {\n          DEFAULT: 'hsl(var(--destructive))',\n          foreground: 'hsl(var(--destructive-foreground))',\n        },\n        muted: {\n          DEFAULT: 'hsl(var(--muted))',\n          foreground: 'hsl(var(--muted-foreground))',\n        },\n        accent: {\n          DEFAULT: 'hsl(var(--accent))',\n          foreground: 'hsl(var(--accent-foreground))',\n        },\n        popover: {\n          DEFAULT: 'hsl(var(--popover))',\n          foreground: 'hsl(var(--popover-foreground))',\n        },\n        card: {\n          DEFAULT: 'hsl(var(--card))',\n          foreground: 'hsl(var(--card-foreground))',\n        },\n      },\n      borderRadius: {\n        lg: 'var(--radius)',\n        md: 'calc(var(--radius) - 2px)',\n        sm: 'calc(var(--radius) - 4px)',\n      },\n      keyframes: {\n        \"accordion-down\": {\n          from: { height: '0' },\n          to: { height: 'var(--radix-accordion-content-height)' },\n        },\n        \"accordion-up\": {\n          from: { height: 'var(--radix-accordion-content-height)' },\n          to: { height: '0' },\n        },\n      },\n      animation: {\n        \"accordion-down\": \"accordion-down 0.2s ease-out\",\n        \"accordion-up\": \"accordion-up 0.2s ease-out\",\n      },\n    },\n  },\n  variants: {\n    extend: {},\n  },\n  plugins: [],\n  future: {\n    hoverOnlyWhenSupported: true,\n  },\n}\n"
  },
  {
    "path": "web/app/vue.config.js",
    "content": "// Note: The fs.Stats deprecation warning is from Vue CLI's webpack dependencies\n// which are not yet compatible with Node.js v23. This is suppressed in the build\n// script. All user dependencies have been updated to their latest versions.\n// Consider migrating to Vite for better Node.js v23+ compatibility.\nmodule.exports = {\n\tfilenameHashing: false,\n\tproductionSourceMap: false,\n\toutputDir: '../static',\n\tpublicPath: '/',\n\tdevServer: {\n\t\tport: 8081,\n\t\thttps: false,\n\t\tclient: {\n\t\t\twebSocketURL:'auto://0.0.0.0/ws'\n\t\t},\n\t\tproxy: {\n\t\t\t'^/api|^/css|^/oicd': {\n\t\t\t\ttarget: \"http://localhost:8080\",\n\t\t\t\tchangeOrigin: true,\n\t\t\t\tsecure: false,\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "web/static/css/app.css",
    "content": "#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}\n\n/*\n! tailwindcss v3.1.8 | MIT License | https://tailwindcss.com\n*/*,: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}"
  },
  {
    "path": "web/static/index.html",
    "content": "<!doctype html><html lang=\"en\" class=\"{{ .Theme }}\"><head><meta charset=\"utf-8\"/><script>window.config = {logo: \"{{ .UI.Logo }}\", header: \"{{ .UI.Header }}\", dashboardHeading: \"{{ .UI.DashboardHeading }}\", dashboardSubheading: \"{{ .UI.DashboardSubheading }}\", link: \"{{ .UI.Link }}\", buttons: [], maximumNumberOfResults: \"{{ .UI.MaximumNumberOfResults }}\", defaultSortBy: \"{{ .UI.DefaultSortBy }}\", defaultFilterBy: \"{{ .UI.DefaultFilterBy }}\"};{{- range .UI.Buttons}}window.config.buttons.push({name:\"{{ .Name }}\",link:\"{{ .Link }}\"});{{end}}\n      // Initialize theme immediately to prevent flash\n      (function() {\n        const themeFromCookie = document.cookie.match(/theme=(dark|light);?/)?.[1];\n        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n        if (themeFromCookie === 'dark' || (!themeFromCookie && prefersDark)) {\n          document.documentElement.classList.add('dark');\n        } else {\n          document.documentElement.classList.remove('dark');\n        }\n      })();</script><title>{{ .UI.Title }}</title><meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"/><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/><link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\"/><link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"{{ .UI.Favicon.Size32x32 }}\"/><link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"{{ .UI.Favicon.Size16x16 }}\"/><link rel=\"manifest\" href=\"/manifest.json\" crossorigin=\"use-credentials\"/><link rel=\"shortcut icon\" href=\"{{ .UI.Favicon.Default }}\"/><link rel=\"stylesheet\" href=\"/css/custom.css\"/><meta name=\"description\" content=\"{{ .UI.Description }}\"/><meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\"/><meta name=\"apple-mobile-web-app-title\" content=\"{{ .UI.Title }}\"/><meta name=\"application-name\" content=\"{{ .UI.Title }}\"/><meta name=\"theme-color\" content=\"#f7f9fb\"/><script defer=\"defer\" src=\"/js/chunk-vendors.js\"></script><script defer=\"defer\" src=\"/js/app.js\"></script><link href=\"/css/app.css\" rel=\"stylesheet\"></head><body><noscript><strong>Enable JavaScript to view this page.</strong></noscript><div id=\"app\"></div></body></html>"
  },
  {
    "path": "web/static/js/app.js",
    "content": "(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;g<t.height+20&&(r=m>t.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<t.width+20&&(i=e.right+n-t.width,i<n+10&&(i=n+10)),o.value=Math.round(r),u.value=Math.round(i)},f=async()=>{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<o.maxResults)e.unshift(null);return e.slice(-o.maxResults)})),v=(0,l.Fl)((()=>{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.length<o.maxResults)e.unshift(null);return e.slice(-o.maxResults)})),g=(0,l.Fl)((()=>o.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<s.options.length&&c(s.options[u.value]);break;case\"Escape\":e.preventDefault(),o.value=!1,u.value=-1;break}else\"Enter\"!==e.key&&\" \"!==e.key&&\"ArrowDown\"!==e.key&&\"ArrowUp\"!==e.key||(e.preventDefault(),g())};return(0,l.bv)((()=>{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,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\").replace(/'/g,\"&#39;\"),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`<a href=\"${o}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-blue-700 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline font-medium\"${i}>${u}</a>`},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<e.length-1?((0,l.wg)(),(0,l.iD)(\"div\",{key:1,class:\"absolute w-0.5 bg-gray-300 dark:bg-gray-600 pointer-events-none\",style:(0,n.j5)({left:\"-16px\",top:\"50%\",height:a===e.length-2?\"calc(50% + 1.25rem)\":\"calc(50% + 2rem)\"})},null,4)):(0,l.kq)(\"\",!0),(0,l._)(\"div\",{class:(0,n.C_)([\"rounded-md border p-3 transition-all duration-200 hover:shadow-sm\",p(s.type).background])},[(0,l._)(\"div\",Is,[(0,l._)(\"time\",{class:(0,n.C_)([\"text-sm font-mono whitespace-nowrap flex-shrink-0\",p(s.type).text]),title:w(s.timestamp)},(0,n.zw)(f(s.timestamp)),11,Ys),(0,l._)(\"div\",Os,[(0,l._)(\"p\",{class:\"text-sm leading-relaxed text-gray-900 dark:text-gray-100\",innerHTML:(0,r.SU)(Hs)(s.message)},null,8,Ps)])])],2)])))),128))])])))),128))])])]))],2)])):(0,l.kq)(\"\",!0)}};const Vs=(0,T.Z)(Ks,[[\"__scopeId\",\"data-v-f1600768\"]]);var Bs=Vs;const Gs={key:0,class:\"past-announcements\"},Js={class:\"space-y-8\"},Xs={class:\"mb-3\"},Qs={class:\"text-sm font-semibold text-muted-foreground uppercase tracking-wider\"},ea={key:0,class:\"space-y-3\"},ta={class:\"flex items-start gap-3\"},sa=[\"title\"],aa={class:\"flex-1 min-w-0\"},la=[\"innerHTML\"],na={key:1,class:\"py-2\"},ra={key:0};var oa={__name:\"PastAnnouncements\",props:{announcements:{type:Array,default:()=>[]}},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<a&&(a=s)}));const l=o(new Date),n=s.value?o(a):new Date(l.getTime()-12096e5),r={},i=l.toDateString();e[i]&&(r[i]=e[i]);for(let t=new Date(l.getTime()-864e5);t>=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)<e))})),d=e=>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<t-1&&(s=Math.max(1,a-t+1));for(let l=s;l<=a;l++)e.push(l);return e})),W=async()=>{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;l<t.events.length;l++){const n=t.events[l];if(\"UNHEALTHY\"!==n.type)continue;const r=new Date(n.timestamp);if(r<s||r>e)continue;let o=null,i=!1;if(l+1<t.events.length){const e=t.events[l+1];o=Z(e.timestamp,n.timestamp)}else o=Z(e,n.timestamp),i=!0;a.push({...n,duration:o,isOngoing:i})}return a})),m=(0,l.Fl)((()=>{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 n<r?s:e}),0);n=i.value[e]}const r=n<=s?\"end\":\"start\";return e[`event-${a}`]={type:\"line\",xMin:new Date(t.timestamp),xMax:new Date(t.timestamp),borderColor:c(),borderWidth:1,borderDash:[5,5],enter(){d.value=a},leave(){d.value=null},label:{display:()=>d.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;e<t.results.length;e++)if(t.results[e].duration>0){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;r<s;r++)if(!e[r].success){n=!0;break}return{name:a?.name||t.name||`Step ${s+1}`,endpoint:a,result:t,status:U(t,a),duration:t.duration||0,isAlwaysRun:n,errors:t.errors||[],nextStepStatus:l?U(l,o.value?.endpoints?.[s+1]):null}}))})),_=(0,l.Fl)((()=>y.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<e.length;d++){a=e[d][0],l=e[d][1],n=e[d][2];for(var o=!0,i=0;i<a.length;i++)(!1&n||r>=n)&&Object.keys(s.O).every((function(e){return s.O[e](a[i])}))?a.splice(i--,1):(o=!1,n<r&&(r=n));if(o){e.splice(d--,1);var u=l();void 0!==u&&(t=u)}}return t}n=n||0;for(var d=e.length;d>0&&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);u<r.length;u++)n=r[u],s.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return s.O(d)},a=self[\"webpackChunkgatus\"]=self[\"webpackChunkgatus\"]||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))}();var a=s.O(void 0,[998],(function(){return s(434)}));a=s.O(a)})();"
  },
  {
    "path": "web/static/js/chunk-vendors.js",
    "content": "\"use strict\";(self[\"webpackChunkgatus\"]=self[\"webpackChunkgatus\"]||[]).push([[998],{262:function(t,e,n){n.d(e,{$y:function(){return wt},BX:function(){return Dt},Bj:function(){return s},Fl:function(){return Nt},IU:function(){return Mt},Jd:function(){return M},PG:function(){return vt},SU:function(){return Rt},Um:function(){return bt},WL:function(){return Lt},X$:function(){return z},X3:function(){return _t},XB:function(){return F},XI:function(){return Ot},Xl:function(){return St},YL:function(){return Tt},YP:function(){return $t},dq:function(){return Ct},fw:function(){return Bt},iH:function(){return At},j:function(){return L},lk:function(){return S},qj:function(){return mt},qq:function(){return c},yT:function(){return kt}});var i=n(577);\n/**\n* @vue/reactivity v3.5.18\n* (c) 2018-present Yuxi (Evan) You and Vue contributors\n* @license MIT\n**/let r,o;class s{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.parent=r,!t&&r&&(this.index=(r.scopes||(r.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){let t,e;if(this._isPaused=!0,this.scopes)for(t=0,e=this.scopes.length;t<e;t++)this.scopes[t].pause();for(t=0,e=this.effects.length;t<e;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){let t,e;if(this._isPaused=!1,this.scopes)for(t=0,e=this.scopes.length;t<e;t++)this.scopes[t].resume();for(t=0,e=this.effects.length;t<e;t++)this.effects[t].resume()}}run(t){if(this._active){const e=r;try{return r=this,t()}finally{r=e}}else 0}on(){1===++this._on&&(this.prevScope=r,r=this)}off(){this._on>0&&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;e<n;e++)this.effects[e].stop();for(this.effects.length=0,e=0,n=this.cleanups.length;e<n;e++)this.cleanups[e]();if(this.cleanups.length=0,this.scopes){for(e=0,n=this.scopes.length;e<n;e++)this.scopes[e].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const t=this.parent.scopes.pop();t&&t!==this&&(this.parent.scopes[this.index]=t,t.index=this.index)}this.parent=void 0}}}function a(){return r}const l=new WeakSet;class c{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,r&&r.active&&r.effects.push(this)}pause(){this.flags|=64}resume(){64&this.flags&&(this.flags&=-65,l.has(this)&&(l.delete(this),this.trigger()))}notify(){2&this.flags&&!(32&this.flags)||8&this.flags||f(this)}run(){if(!(1&this.flags))return this.fn();this.flags|=2,T(this),m(this);const t=o,e=k;o=this,k=!0;try{return this.fn()}finally{0,b(this),o=t,k=e,this.flags&=-3}}stop(){if(1&this.flags){for(let t=this.deps;t;t=t.nextDep)v(t);this.deps=this.depsTail=void 0,T(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){64&this.flags?l.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){x(this)&&this.run()}get dirty(){return x(this)}}let u,h,d=0;function f(t,e=!1){if(t.flags|=8,e)return t.next=h,void(h=t);t.next=u,u=t}function p(){d++}function g(){if(--d>0)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.length:(0,i.RI)(t,e),a=Reflect.set(t,e,n,Ct(t)?t:r);return t===Mt(r)&&(s?(0,i.aU)(n,o)&&z(t,\"set\",e,n,o):z(t,\"add\",e,n)),a}deleteProperty(t,e){const n=(0,i.RI)(t,e),r=t[e],o=Reflect.deleteProperty(t,e);return o&&n&&z(t,\"delete\",e,void 0,r),o}has(t,e){const n=Reflect.has(t,e);return(0,i.yk)(e)&&q.has(e)||L(t,\"has\",e),n}ownKeys(t){return L(t,\"iterate\",(0,i.kJ)(t)?\"length\":E),Reflect.ownKeys(t)}}class Q extends G{constructor(t=!1){super(!0,t)}set(t,e){return!0}deleteProperty(t,e){return!0}}const J=new Z,K=new Q,tt=new Z(!0),et=t=>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<t.length;i++)Bt(t[i],e,n);else if((0,i.DM)(t)||(0,i._N)(t))t.forEach((t=>{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<t.length;o++)r.push(s(t[o],e,n,i));return r}}function a(t,e,n,s=!0){const a=e?e.vnode:null,{errorHandler:c,throwUnhandledErrorInProduction:u}=e&&e.appContext.config||r.kT;if(e){let r=e.parent;const s=e.proxy,a=`https://vuejs.org/error-reference/#runtime-${n}`;while(r){const e=r.ec;if(e)for(let n=0;n<e.length;n++)if(!1===e[n](t,s,a))return;r=r.parent}if(c)return(0,i.Jd)(),o(c,null,10,[t,s,a]),void(0,i.lk)()}l(t,n,a,s,u)}function l(t,e,n,i=!0,r=!1){if(r)throw t;console.error(t)}const c=[];let u=-1;const h=[];let d=null,f=0;const p=Promise.resolve();let g=null;function m(t){const e=g||p;return t?e.then(this?t.bind(this):t):e}function b(t){let e=u+1,n=c.length;while(e<n){const i=e+n>>>1,r=c[i],o=_(r);o<t||o===t&&2&r.flags?e=i+1:n=i}return e}function x(t){if(!(1&t.flags)){const e=_(t),n=c[c.length-1];!n||!(2&t.flags)&&e>=_(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<c.length;n++){const e=c[n];if(e&&2&e.flags){if(t&&e.id!==t.uid)continue;0,c.splice(n,1),n--,4&e.flags&&(e.flags&=-2),e(),4&e.flags||(e.flags&=-2)}}}function k(t){if(h.length){const t=[...new Set(h)].sort(((t,e)=>_(t)-_(e)));if(h.length=0,d)return void d.push(...t);for(d=t,f=0;f<d.length;f++){const t=d[f];0,4&t.flags&&(t.flags&=-2),8&t.flags||t(),t.flags&=-2}d=null,f=0}}const _=t=>null==t.id?2&t.flags?-1:1/0:t.id;function M(t){r.dG;try{for(u=0;u<c.length;u++){const t=c[u];!t||8&t.flags||(4&t.flags&&(t.flags&=-2),o(t,t.i,t.i?15:14),4&t.flags||(t.flags&=-2))}}finally{for(;u<c.length;u++){const t=c[u];t&&(t.flags&=-2)}u=-1,c.length=0,k(t),g=null,(c.length||h.length)&&M(t)}}let S=null,T=null;function D(t){const e=S;return S=t,T=t&&t.type.__scopeId||null,e}function C(t,e=S,n){if(!e)return t;if(t._n)return t;const i=(...n)=>{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;s<e.length;s++){let[t,a,l,c=r.kT]=e[s];t&&((0,r.mf)(t)&&(t={mounted:t,updated:t}),t.deep&&(0,i.fw)(a),o.push({dir:t,instance:n,value:a,oldValue:void 0,arg:l,modifiers:c}))}return t}function O(t,e,n,r){const o=t.dirs,a=e&&e.dirs;for(let l=0;l<o.length;l++){const c=o[l];a&&(c.oldValue=a[l].value);let u=c.dir[r];u&&((0,i.Jd)(),s(u,n,8,[t.el,c,t,e]),(0,i.lk)())}}const P=Symbol(\"_vte\"),E=t=>t.__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;o<t.length;o++){let s=t[o];const a=null==n?s.key:String(n)+String(null!=s.key?s.key:o);s.type===He?(128&s.patchFlag&&r++,i=i.concat(W(s.children,e,a))):(e||s.type!==$e)&&i.push(null!=a?ln(s,{key:a}):s)}if(r>1)for(let o=0;o<i.length;o++)i[o].patchFlag=-2;return i}\n/*! #__NO_SIDE_EFFECTS__ */function $(t,e){return(0,r.mf)(t)?(()=>(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\n/*! #__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;l<c;l++)s[l]=e(r?o?(0,i.BX)((0,i.YL)(t[l])):(0,i.YL)(t[l]):t[l],l,void 0,a&&a[l])}else if(\"number\"===typeof t){0,s=new Array(t);for(let n=0;n<t;n++)s[n]=e(n+1,n,void 0,a&&a[n])}else if((0,r.Kn)(t))if(t[Symbol.iterator])s=Array.from(t,((t,n)=>e(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<r;i++){const r=n[i];s[i]=e(t[r],r,i,a&&a[i])}}else s=[];return n&&(n[o]=s),s}function yt(t,e,n={},i,o){if(S.ce||S.parent&&V(S.parent)&&S.parent.ce)return\"default\"!==e&&(n.name=e),Ue(),Je(He,null,[on(\"slot\",n,i&&i())],64);let s=t[e];s&&s._c&&(s._d=!1),Ue();const a=s&&vt(s(n)),l=n.key||a&&a.key,c=Je(He,{key:(l&&!(0,r.yk)(l)?l:`_${e}`)+(!a&&i?\"_fb\":\"\")},a||(i?i():[]),a&&1===t._?64:-2);return!o&&c.scopeId&&(c.slotScopeIds=[c.scopeId+\"-s\"]),s&&s._c&&(s._d=!0),c}function vt(t){return t.some((t=>!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;n<t.length;n++)e[t[n]]=t[n];return e}return t}function Nt(t,e){return t?[...new Set([].concat(t,e))]:e}function Ft(t,e){return t?(0,r.l7)(Object.create(null),t,e):e}function jt(t,e){return t?(0,r.kJ)(t)&&(0,r.kJ)(e)?[...new Set([...t,...e])]:(0,r.l7)(Object.create(null),St(t),St(null!=e?e:{})):e}function Ht(t,e){if(!t)return e;if(!e)return t;const n=(0,r.l7)(Object.create(null),t);for(const i in e)n[i]=Nt(t[i],e[i]);return n}function Wt(){return{app:null,config:{isNativeTag:r.NO,performance:!1,globalProperties:{},optionMergeStrategies:{},errorHandler:void 0,warnHandler:void 0,compilerOptions:{}},mixins:[],components:{},directives:{},provides:Object.create(null),optionsCache:new WeakMap,propsCache:new WeakMap,emitsCache:new WeakMap}}let $t=0;function Bt(t,e){return function(n,i=null){(0,r.mf)(n)||(n=(0,r.l7)({},n)),null==i||(0,r.Kn)(i)||(i=null);const o=Wt(),a=new WeakSet,l=[];let c=!1;const u=o.app={_uid:$t++,_component:n,_props:i,_container:null,_context:o,_instance:null,version:Hn,get config(){return o.config},set config(t){0},use(t,...e){return a.has(t)||(t&&(0,r.mf)(t.install)?(a.add(t),t.install(u,...e)):(0,r.mf)(t)&&(a.add(t),t(u,...e))),u},mixin(t){return o.mixins.includes(t)||o.mixins.push(t),u},component(t,e){return e?(o.components[t]=e,u):o.components[t]},directive(t,e){return e?(o.directives[t]=e,u):o.directives[t]},mount(r,s,a){if(!c){0;const l=u._ceVNode||on(n,i);return l.appContext=o,!0===a?a=\"svg\":!1===a&&(a=void 0),s&&e?e(l,r):t(l,r,a),c=!0,u._container=r,r.__vue_app__=u,Ln(l.component)}},onUnmount(t){l.push(t)},unmount(){c&&(s(l,u._instance,16),t(null,u._container),delete u._container.__vue_app__)},provide(t,e){return o.provides[t]=e,u},runWithContext(t){const e=Yt;Yt=u;try{return t()}finally{Yt=e}}};return u}}let Yt=null;function Vt(t,e){if(yn){let n=yn.provides;const i=yn.parent&&yn.parent.provides;i===n&&(n=yn.provides=Object.create(i)),n[t]=e}else 0}function Ut(t,e,n=!1){const i=vn();if(i||Yt){let o=Yt?Yt._context.provides:i?null==i.parent||i.ce?i.vnode.appContext&&i.vnode.appContext.provides:i.parent.provides:void 0;if(o&&t in o)return o[t];if(arguments.length>1)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<n.length;i++){let o=n[i];if(Pe(t.emitsOptions,o))continue;const l=e[o];if(u)if((0,r.RI)(a,o))l!==a[o]&&(a[o]=l,h=!0);else{const e=(0,r._A)(o);s[e]=Kt(u,c,e,l,t,!1)}else l!==a[o]&&(a[o]=l,h=!0)}}h&&(0,i.X$)(t.attrs,\"set\",\"\")}function Jt(t,e,n,o){const[s,a]=t.propsOptions;let l,c=!1;if(e)for(let i in e){if((0,r.Gg)(i))continue;const u=e[i];let h;s&&(0,r.RI)(s,h=(0,r._A)(i))?a&&a.includes(h)?(l||(l={}))[h]=u:n[h]=u:Pe(t.emitsOptions,i)||i in o&&u===o[i]||(o[i]=u,c=!0)}if(a){const e=(0,i.IU)(n),o=l||r.kT;for(let i=0;i<a.length;i++){const l=a[i];n[l]=Kt(s,e,l,o[l],t,!(0,r.RI)(o,l))}}return c}function Kt(t,e,n,i,o,s){const a=t[n];if(null!=a){const t=(0,r.RI)(a,\"default\");if(t&&void 0===i){const t=a.default;if(a.type!==Function&&!a.skipFactory&&(0,r.mf)(t)){const{propsDefaults:r}=o;if(n in r)i=r[n];else{const s=_n(o);i=r[n]=t.call(null,e),s()}}else i=t;o.ce&&o.ce._setProp(n,i)}a[0]&&(s&&!t?i=!1:!a[1]||\"\"!==i&&i!==(0,r.rs)(n)||(i=!0))}return i}const te=new WeakMap;function ee(t,e,n=!1){const i=n?te:e.propsCache,o=i.get(t);if(o)return o;const s=t.props,a={},l=[];let c=!1;if(!(0,r.mf)(t)){const i=t=>{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<s.length;h++){0;const t=(0,r._A)(s[h]);ne(t)&&(a[t]=r.kT)}else if(s){0;for(const t in s){const e=(0,r._A)(t);if(ne(e)){const n=s[t],i=a[e]=(0,r.kJ)(n)||(0,r.mf)(n)?{type:n}:(0,r.l7)({},n),o=i.type;let c=!1,u=!0;if((0,r.kJ)(o))for(let t=0;t<o.length;++t){const e=o[t],n=(0,r.mf)(e)&&e.name;if(\"Boolean\"===n){c=!0;break}\"String\"===n&&(u=!1)}else c=(0,r.mf)(o)&&\"Boolean\"===o.name;i[0]=c,i[1]=u,(c||(0,r.RI)(i,\"default\"))&&l.push(e)}}}const u=[a,l];return(0,r.Kn)(t)&&i.set(t,u),u}function ne(t){return\"$\"!==t[0]&&!(0,r.Gg)(t)}const ie=t=>\"_\"===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<i.length;o++)g(t,i[o]);if(r){let n=r.subTree;if(e===n||Fe(n.type)&&(n.ssContent===e||n.ssFallback===e)){const e=r.vnode;C(t,e,e.scopeId,e.slotScopeIds,r.parent)}}},A=(t,e,n,i,r,o,s,a,l=0)=>{for(let c=l;c<t.length;c++){const l=t[c]=a?dn(t[c]):hn(t[c]);b(null,l,e,n,i,r,o,s,a)}},E=(t,e,n,i,o,s,l)=>{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<t.length;e++){const i=t[e],r=p[i],s=g[i];s===r&&\"value\"!==i||a(c,i,r,s,o,n)}}1&u&&t.children!==e.children&&d(c,e.children)}else l||null!=h||I(c,p,g,n,o);((m=g.onVnodeUpdated)||f)&&de((()=>{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<e.length;a++){const l=t[a],c=e[a],u=l.el&&(l.type===He||!tn(l,c)||198&l.shapeFlag)?f(l.el):n;b(l,c,u,null,i,r,o,s,!0)}},I=(t,e,n,i,o)=>{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;f<d;f++){const i=e[f]=c?dn(e[f]):hn(e[f]);b(t[f],i,n,null,o,s,a,l,c)}u>h?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=t<h?e[t].el:i;while(u<=f)b(null,e[u]=c?dn(e[u]):hn(e[u]),n,r,o,s,a,l,c),u++}}else if(u>f)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;u++)_[u]=0;for(u=p;u<=d;u++){const i=t[u];if(y>=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<h?d.el||d.placeholder:i;0===_[u]?b(null,r,n,f,o,s,a,l,c):w&&(x<0||u!==M[x]?q(r,n,f,2):x--)}}},q=(t,e,n,i,r=null)=>{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;t<u.length;t++)q(u[t],e,n,i);return void o(t.anchor,e,n)}if(l===Be)return void M(t,e,n);const d=2!==i&&1&h&&c;if(d)if(0===i)c.beforeEnter(a),o(a,e,n),de((()=>c.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<t.length;s++)X(t[s],e,n,i,r)},K=t=>{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<i.length;r++){const t=i[r];let e=o[r];1&e.shapeFlag&&!e.dynamicChildren&&((e.patchFlag<=0||32===e.patchFlag)&&(e=o[r]=dn(o[r]),e.el=t.el),n||-2===e.patchFlag||xe(t,e)),e.type===We&&(e.el=t.el),e.type!==$e||e.el||(e.el=t.el)}}function ye(t){const e=t.slice(),n=[0];let i,r,o,s,a;const l=t.length;for(i=0;i<l;i++){const l=t[i];if(0!==l){if(r=n[n.length-1],t[r]<l){e[i]=r,n.push(i);continue}o=0,s=n.length-1;while(o<s)a=o+s>>1,t[n[a]]<l?o=a+1:s=a;l<t[n[o]]&&(o>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<t.length;e++)t[e].flags|=8}const ke=Symbol.for(\"v-scx\"),_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<n.length&&e;t++)e=e[n[t]];return e}}const Ce=(t,e)=>\"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;e<t.length;e++){const n=t[e];if(s[n]!==i[n]&&!Pe(c,n))return!0}}return!1}function ze(t,e,n){const i=Object.keys(e);if(i.length!==Object.keys(t).length)return!0;for(let r=0;r<i.length;r++){const o=i[r];if(e[o]!==t[o]&&!Pe(n,o))return!0}return!1}function Ne({vnode:t,parent:e},n){while(e){const i=e.subTree;if(i.suspense&&i.suspense.activeBranch===t&&(i.el=t.el),i!==t)break;(t=e.vnode).el=n,e=e.parent}}const Fe=t=>t.__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;n<t.length;n++){const i=t[n];for(const t in i)if(\"class\"===t)e.class!==i.class&&(e.class=(0,r.C_)([e.class,i.class]));else if(\"style\"===t)e.style=(0,r.j5)([e.style,i.style]);else if((0,r.F7)(t)){const n=e[t],o=i[t];!o||n===o||(0,r.kJ)(n)&&n.includes(o)||(e[t]=n?[].concat(n,o):o)}else\"\"!==t&&(e[t]=i[t])}return e}function gn(t,e,n,i=null){s(t,e,7,[n,i])}const mn=Wt();let bn=0;function xn(t,e,n){const o=t.type,s=(e?e.appContext:t.appContext)||mn,a={uid:bn++,vnode:t,type:o,parent:e,appContext:s,root:null,next:null,subTree:null,effect:null,update:null,job:null,scope:new i.Bj(!0),render:null,proxy:null,exposed:null,exposeProxy:null,withProxy:null,provides:e?e.provides:Object.create(s.provides),ids:e?e.ids:[\"\",0,0],accessCache:null,renderCache:[],components:null,directives:null,propsOptions:ee(o,s),emitsOptions:Oe(o,s),emit:null,emitted:null,propsDefaults:r.kT,inheritAttrs:o.inheritAttrs,ctx:r.kT,data:r.kT,props:r.kT,attrs:r.kT,slots:r.kT,refs:r.kT,setupState:r.kT,setupContext:null,suspense:n,suspenseId:n?n.pendingId:0,asyncDep:null,asyncResolved:!1,isMounted:!1,isUnmounted:!1,isDeactivated:!1,bc:null,c:null,bm:null,m:null,bu:null,u:null,um:null,bum:null,da:null,a:null,rtg:null,rtc:null,ec:null,sp:null};return a.ctx={_:a},a.root=e?e.root:a,a.emit=Ae.bind(null,a),t.ce&&t.ce(a),a}let yn=null;const vn=()=>yn||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);\n/**\n* @vue/runtime-dom v3.5.18\n* (c) 2018-present Yuxi (Evan) You and Vue contributors\n* @license MIT\n**/\nlet 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?`<svg>${t}</svg>`:\"mathml\"===i?`<math>${t}</math>`: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}\n/*! #__NO_SIDE_EFFECTS__ */\n\"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;o<s;o++){const s=t.options[o],a=q(s);if(n)if(i){const t=typeof a;s.selected=\"string\"===t||\"number\"===t?e.some((t=>String(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<e.length;t++){const i=G[e[t]];if(i&&i(n,e))return}return t(n,...i)})},Q={esc:\"escape\",space:\" \",up:\"arrow-up\",left:\"arrow-left\",right:\"arrow-right\",down:\"arrow-down\",delete:\"backspace\"},J=(t,e)=>{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){\n/**\n* @vue/shared v3.5.18\n* (c) 2018-present Yuxi (Evan) You and Vue contributors\n* @license MIT\n**/\n/*! #__NO_SIDE_EFFECTS__ */\nfunction 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<t.length;n++)t[n](...e)},j=(t,e,n,i=!1)=>{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<t.length;n++){const i=t[n],r=v(i)?Z(i):U(i);if(r)for(const t in r)e[t]=r[t]}return e}if(v(t)||k(t))return t}const q=/;(?![^(]*\\))/g,X=/:([^]+)/,G=/\\/\\*[^]*?\\*\\//g;function Z(t){const e={};return t.replace(G,\"\").split(q).forEach((t=>{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;n<t.length;n++){const i=Q(t[n]);i&&(e+=i+\" \")}else if(k(t))for(const n in t)t[n]&&(e+=n+\" \");return e.trim()}const J=\"itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly\",K=i(J);function tt(t){return!!t||\"\"===t}function et(t,e){if(t.length!==e.length)return!1;let n=!0;for(let i=0;n&&i<t.length;i++)n=nt(t[i],e[i]);return n}function nt(t,e){if(t===e)return!0;let n=b(t),i=b(e);if(n||i)return!(!n||!i)&&t.getTime()===e.getTime();if(n=w(t),i=w(e),n||i)return t===e;if(n=p(t),i=p(e),n||i)return!(!n||!i)&&et(t,e);if(n=k(t),i=k(e),n||i){if(!n||!i)return!1;const r=Object.keys(t).length,o=Object.keys(e).length;if(r!==o)return!1;for(const n in t){const i=t.hasOwnProperty(n),r=e.hasOwnProperty(n);if(i&&!r||!i&&r||!nt(t[n],e[n]))return!1}}return String(t)===String(e)}function it(t,e){return t.findIndex((t=>nt(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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */\nconst 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;\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */\nvar 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\"};\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */\nconst 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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/**\n * @license lucide-vue-next v0.539.0 - ISC\n *\n * This source code is licensed under the ISC license.\n * See the LICENSE file in the root directory of this source tree.\n */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);\n/*!\n * Chart.js v4.5.1\n * https://www.chartjs.org\n * (c) 2025 Chart.js Contributors\n * Released under the MIT License\n */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||e<n),!this._active)return this._target[i]=s,void this._notify(!0);e<0?this._target[i]=r:(a=e/n%2,a=o&&a>1?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<n.length;i++)n[i][e]()}}class c{constructor(t,e){this._chart=t,this._properties=new Map,this.configure(e)}configure(t){if(!(0,i.i)(t))return;const e=Object.keys(i.d.animation),n=this._properties;Object.getOwnPropertyNames(t).forEach((r=>{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;r<i.length;r++){const e=t[i[r]];e&&e.active()&&n.push(e.wait())}return Promise.all(n)}function h(t,e){if(!e)return;let n=t.options;if(n)return n.$shared&&(t.options=n=Object.assign({},n,{$shared:!1,$animations:{}})),n;t.options=e}function d(t,e){const n=t&&t.options||{},i=n.reverse,r=void 0===n.min?e:0,o=void 0===n.max?e:0;return{start:i?o:r,end:i?r:o}}function f(t,e,n){if(!1===n)return!1;const i=d(t,n),r=d(e,n);return{top:r.end,right:i.end,bottom:r.start,left:i.start}}function p(t){let e,n,r,o;return(0,i.i)(t)?(e=t.top,n=t.right,r=t.bottom,o=t.left):e=n=r=o=t,{top:e,right:n,bottom:r,left:o,disabled:!1===t}}function g(t,e){const n=[],i=t._getSortedDatasetMetas(e);let r,o;for(r=0,o=i.length;r<o;++r)n.push(i[r].index);return n}function m(t,e,n,r={}){const o=t.keys,s=\"single\"===r.mode;let a,l,c,u;if(null===e)return;let h=!1;for(a=0,l=o.length;a<l;++a){if(c=+o[a],c===n){if(h=!0,r.all)continue;break}u=t.values[c],(0,i.g)(u)&&(s||0===e||(0,i.s)(e)===(0,i.s)(u))&&(e+=u)}return h||r.all?e:0}function b(t,e){const{iScale:n,vScale:i}=e,r=\"x\"===n.axis?\"x\":\"y\",o=\"x\"===i.axis?\"x\":\"y\",s=Object.keys(t),a=new Array(s.length);let l,c,u;for(l=0,c=s.length;l<c;++l)u=s[l],a[l]={[r]:u,[o]:t[u]};return a}function x(t,e){const n=t&&t.options.stacked;return n||void 0===n&&void 0!==e.stack}function y(t,e,n){return`${t.id}.${e.id}.${n.stack||n.type}`}function v(t){const{min:e,max:n,minDefined:i,maxDefined:r}=t.getUserBounds();return{min:i?e:Number.NEGATIVE_INFINITY,max:r?n:Number.POSITIVE_INFINITY}}function w(t,e,n){const i=t[e]||(t[e]={});return i[n]||(i[n]={})}function k(t,e,n,i){for(const r of e.getMatchingVisibleMetas(i).reverse()){const e=t[r.index];if(n&&e>0||!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;f<h;++f){const t=e[f],{[l]:n,[c]:o}=t,h=t._stacks||(t._stacks={});d=h[c]=w(r,u,n),d[a]=o,d._top=k(d,s,!0,i.type),d._bottom=k(d,s,!1,i.type);const p=d._visualValues||(d._visualValues={});p[a]=o}}function M(t,e){const n=t.scales;return Object.keys(n).filter((t=>n[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]<d[a];for(l=0;l<e;++l)n._parsed[l+t]=c=u[l],h&&(o()&&(h=!1),d=c);n._sorted=h}s&&_(this,u)}parsePrimitiveData(t,e,n,i){const{iScale:r,vScale:o}=t,s=r.axis,a=o.axis,l=r.getLabels(),c=r===o,u=new Array(i);let h,d,f;for(h=0,d=i;h<d;++h)f=h+n,u[h]={[s]:c||r.parse(l[f],f),[a]:o.parse(e[f],f)};return u}parseArrayData(t,e,n,i){const{xScale:r,yScale:o}=t,s=new Array(i);let a,l,c,u;for(a=0,l=i;a<l;++a)c=a+n,u=e[c],s[a]={x:r.parse(u[0],c),y:o.parse(u[1],c)};return s}parseObjectData(t,e,n,r){const{xScale:o,yScale:s}=t,{xAxisKey:a=\"x\",yAxisKey:l=\"y\"}=this._parsing,c=new Array(r);let u,h,d,f;for(u=0,h=r;u<h;++u)d=u+n,f=e[d],c[u]={x:o.parse((0,i.f)(f,a),d),y:s.parse((0,i.f)(f,l),d)};return c}getParsed(t){return this._cachedMeta._parsed[t]}getDataElement(t){return this._cachedMeta.data[t]}applyStack(t,e,n){const i=this.chart,r=this._cachedMeta,o=e[t.axis],s={keys:g(i,!0),values:e._stacks[t.axis]._visualValues};return m(s,o,r.index,{mode:n})}updateRangeFromParsed(t,e,n,i){const r=n[e.axis];let o=null===r?NaN:r;const s=i&&n._stacks[e.axis];i&&s&&(i.values=s,o=m(i,r,this._cachedMeta.index)),t.min=Math.min(t.min,o),t.max=Math.max(t.max,o)}getMinMax(t,e){const n=this._cachedMeta,r=n._parsed,o=n._sorted&&t===n.iScale,s=r.length,a=this._getOtherScale(t),l=O(e,n,this.chart),c={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:u,max:h}=v(a);let d,f;function p(){f=r[d];const e=f[a.axis];return!(0,i.g)(f[t.axis])||u>e||h<e}for(d=0;d<s;++d)if(!p()&&(this.updateRangeFromParsed(c,t,f,l),o))break;if(o)for(d=s-1;d>=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<o;++r)s=e[r][t.axis],(0,i.g)(s)&&n.push(s);return n}getMaxOverflow(){return!1}getLabelAndValue(t){const e=this._cachedMeta,n=e.iScale,i=e.vScale,r=this.getParsed(t);return{label:n?\"\"+n.getLabelForValue(r[n.axis]):\"\",value:i?\"\"+i.getLabelForValue(r[i.axis]):\"\"}}_update(t){const e=this._cachedMeta;this.update(t||\"default\"),e._clip=p((0,i.v)(this.options.clip,f(e.xScale,e.yScale,this.getMaxOverflow())))}update(t){}draw(){const t=this._ctx,e=this.chart,n=this._cachedMeta,i=n.data||[],r=e.chartArea,o=[],s=this._drawStart||0,a=this._drawCount||i.length-s,l=this.options.drawActiveElementsOnTop;let c;for(n.dataset&&n.dataset.draw(t,r,s,a),c=s;c<s+a;++c){const e=i[c];e.hidden||(e.active&&l?o.push(e):e.draw(t,r))}for(c=0;c<o.length;++c)o[c].draw(t,r)}getStyle(t,e){const n=e?\"active\":\"default\";return void 0===t&&this._cachedMeta.dataset?this.resolveDatasetElementOptions(n):this.resolveDataElementOptions(t||0,n)}getContext(t,e,n){const i=this.getDataset();let r;if(t>=0&&t<this._cachedMeta.data.length){const e=this._cachedMeta.data[t];r=e.$context||(e.$context=T(this.getContext(),t,e)),r.parsed=this.getParsed(t),r.raw=i.data[t],r.index=r.dataIndex=t}else r=this.$context||(this.$context=S(this.chart.getContext(),this.index)),r.dataset=i,r.index=r.datasetIndex=this.index;return r.active=!!e,r.mode=n,r}resolveDatasetElementOptions(t){return this._resolveElementOptions(this.datasetElementType.id,t)}resolveDataElementOptions(t,e){return this._resolveElementOptions(this.dataElementType.id,e,t)}_resolveElementOptions(t,e=\"default\",n){const r=\"active\"===e,o=this._cachedDataOpts,s=t+\"-\"+e,a=o[s],l=this.enableOptionSharing&&(0,i.h)(n);if(a)return A(a,l);const c=this.chart.config,u=c.datasetElementScopeKeys(this._type,t),h=r?[`${t}Hover`,\"hover\",t,\"\"]:[t,\"\"],d=c.getOptionScopes(this.getDataset(),u),f=Object.keys(i.d.elements[t]),p=()=>this.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<i&&this._removeElements(r,i-r)}_insertElements(t,e,n=!0){const i=this._cachedMeta,r=i.data,o=t+e;let s;const a=t=>{for(t.length+=e,s=t.length-1;s>=o;s--)t[s]=t[s-e]};for(a(r),s=t;s<o;++s)r[s]=new this.dataElementType;this._parsing&&a(i._parsed),this.parse(t,e),n&&this.updateElements(r,t,e,\"reset\")}updateElements(t,e,n,i){}_removeElements(t,e){const n=this._cachedMeta;if(this._parsing){const i=n._parsed.splice(t,e);n._stacked&&D(n,i)}n.data.splice(t,e)}_sync(t){if(this._parsing)this._syncList.push(t);else{const[e,n,i]=t;this[e](n,i)}this.chart._dataChanges.push([this.index,...t])}_onDataPush(){const t=arguments.length;this._sync([\"_insertElements\",this.getDataset().data.length-t,t])}_onDataPop(){this._sync([\"_removeElements\",this._cachedMeta.data.length-1,1])}_onDataShift(){this._sync([\"_removeElements\",0,1])}_onDataSplice(t,e){e&&this._sync([\"_removeElements\",t,e]);const n=arguments.length-2;n&&this._sync([\"_insertElements\",t,n])}_onDataUnshift(){this._sync([\"_insertElements\",0,arguments.length])}}function E(t,e,n){let r=1,o=1,s=0,a=0;if(e<i.T){const l=t,c=l+e,u=Math.cos(l),h=Math.sin(l),d=Math.cos(c),f=Math.sin(c),p=(t,e,r)=>(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;o<s;++o)r._parsed[o]=a(o)}}_getRotation(){return(0,i.t)(this.options.rotation-90)}_getCircumference(){return(0,i.t)(this.options.circumference)}_getRotationExtents(){let t=i.T,e=-i.T;for(let n=0;n<this.chart.data.datasets.length;++n)if(this.chart.isDatasetVisible(n)&&this.chart.getDatasetMeta(n).type===this._type){const i=this.chart.getDatasetMeta(n).controller,r=i._getRotation(),o=i._getCircumference();t=Math.min(t,r),e=Math.max(e,r+o)}return{rotation:t,circumference:e-t}}update(t){const e=this.chart,{chartArea:n}=e,r=this._cachedMeta,o=r.data,s=this.getMaxBorderWidth()+this.getMaxOffset(o)+this.options.spacing,a=Math.max((Math.min(n.width,n.height)-s)/2,0),l=Math.min((0,i.m)(this.options.cutout,a),1),c=this._getRingWeight(this.index),{circumference:u,rotation:h}=this._getRotationExtents(),{ratioX:d,ratioY:f,offsetX:p,offsetY:g}=E(h,u,l),m=(n.width-s)/d,b=(n.height-s)/f,x=Math.max(Math.min(m,b)/2,0),y=(0,i.n)(this.options.radius,x),v=Math.max(y*l,0),w=(y-v)/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=g*y,r.total=this.calculateTotal(),this.outerRadius=y-w*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-w*c,0),this.updateElements(o,0,o.length,t)}_circumference(t,e){const n=this.options,r=this._cachedMeta,o=this._getCircumference();return e&&n.animation.animateRotate||!this.chart.getDataVisibility(t)||null===r._parsed[t]||r.data[t].hidden?0:this.calculateCircumference(r._parsed[t]*o/i.T)}updateElements(t,e,n,i){const r=\"reset\"===i,o=this.chart,s=o.chartArea,a=o.options,l=a.animation,c=(s.left+s.right)/2,u=(s.top+s.bottom)/2,h=r&&l.animateScale,d=h?0:this.innerRadius,f=h?0:this.outerRadius,{sharedOptions:p,includeOptions:g}=this._getSharedOptions(e,i);let m,b=this._getRotation();for(m=0;m<e;++m)b+=this._circumference(m,r);for(m=e;m<e+n;++m){const e=this._circumference(m,r),n=t[m],o={x:c+this.offsetX,y:u+this.offsetY,startAngle:b,endAngle:b+e,circumference:e,outerRadius:f,innerRadius:d};g&&(o.options=p||this.resolveDataElementOptions(m,n.active?\"active\":i)),b+=e,this.updateElement(n,m,o,i)}}calculateTotal(){const t=this._cachedMeta,e=t.data;let n,i=0;for(n=0;n<e.length;n++){const r=t._parsed[n];null===r||isNaN(r)||!this.chart.getDataVisibility(n)||e[n].hidden||(i+=Math.abs(r))}return i}calculateCircumference(t){const e=this._cachedMeta.total;return e>0&&!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;i<r;++i)if(n.isDatasetVisible(i)){o=n.getDatasetMeta(i),t=o.data,s=o.controller;break}if(!t)return 0;for(i=0,r=t.length;i<r;++i)a=s.resolveDataElementOptions(i),\"inner\"!==a.borderAlign&&(e=Math.max(e,a.borderWidth||0,a.hoverBorderWidth||0));return e}getMaxOffset(t){let e=0;for(let n=0,i=t.length;n<i;++n){const t=this.resolveDataElementOptions(n);e=Math.max(e,t.offset||0,t.hoverOffset||0)}return e}_getRingWeightOffset(t){let e=0;for(let n=0;n<t;++n)this.chart.isDatasetVisible(n)&&(e+=this._getRingWeight(n));return e}_getRingWeight(t){return Math.max((0,i.v)(this.chart.data.datasets[t].weight,1),0)}_getVisibleDatasetWeightTotal(){return this._getRingWeightOffset(this.chart.data.datasets.length)||1}}class I extends P{static id=\"line\";static defaults={datasetElementType:\"line\",dataElementType:\"point\",showLine:!0,spanGaps:!1};static overrides={scales:{_index_:{type:\"category\"},_value_:{type:\"linear\"}}};initialize(){this.enableOptionSharing=!0,this.supportsDecimation=!0,super.initialize()}update(t){const e=this._cachedMeta,{dataset:n,data:r=[],_dataset:o}=e,s=this.chart._animationsDisabled;let{start:a,count:l}=(0,i.q)(e,r,s);this._drawStart=a,this._drawCount=l,(0,i.w)(e)&&(a=0,l=r.length),n._chart=this.chart,n._datasetIndex=this.index,n._decimated=!!o._decimated,n.points=r;const c=this.resolveDatasetElementOptions(t);this.options.showLine||(c.borderWidth=0),c.segment=this.options.segment,this.updateElement(n,void 0,{animated:!s,options:c},t),this.updateElements(r,a,l,t)}updateElements(t,e,n,r){const o=\"reset\"===r,{iScale:s,vScale:a,_stacked:l,_dataset:c}=this._cachedMeta,{sharedOptions:u,includeOptions:h}=this._getSharedOptions(e,r),d=s.axis,f=a.axis,{spanGaps:p,segment:g}=this.options,m=(0,i.x)(p)?p:Number.POSITIVE_INFINITY,b=this.chart._animationsDisabled||o||\"none\"===r,x=e+n,y=t.length;let v=e>0&&this.getParsed(e-1);for(let w=0;w<y;++w){const n=t[w],p=b?n:{};if(w<e||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<l;++a){const{index:t,data:n}=o[a],{lo:l,hi:c}=F(o[a],e,s,r);for(let e=l;e<=c;++e){const r=n[e];r.skip||i(r,t,e)}}}function H(t){const e=-1!==t.indexOf(\"x\"),n=-1!==t.indexOf(\"y\");return function(t,i){const r=e?Math.abs(t.x-i.x):0,o=n?Math.abs(t.y-i.y):0;return Math.sqrt(Math.pow(r,2)+Math.pow(o,2))}}function W(t,e,n,r,o){const s=[];if(!o&&!t.isPointInArea(e))return s;const a=function(n,a,l){(o||(0,i.C)(n,t.chartArea,0))&&n.inRange(e.x,e.y,r)&&s.push({element:n,datasetIndex:a,index:l})};return j(t,n,e,a,!0),s}function $(t,e,n,r){let o=[];function s(t,n,s){const{startAngle:a,endAngle:l}=t.getProps([\"startAngle\",\"endAngle\"],r),{angle:c}=(0,i.D)(t,{x:e.x,y:e.y});(0,i.p)(c,a,l)&&o.push({element:t,datasetIndex:n,index:s})}return j(t,n,e,s),o}function B(t,e,n,i,r,o){let s=[];const a=H(n);let l=Number.POSITIVE_INFINITY;function c(n,c,u){const h=n.inRange(e.x,e.y,r);if(i&&!h)return;const d=n.getCenterPoint(r),f=!!o||t.isPointInArea(d);if(!f&&!h)return;const p=a(e,d);p<l?(s=[{element:n,datasetIndex:c,index:u}],l=p):p===l&&s.push({element:n,datasetIndex:c,index:u})}return j(t,n,e,c),s}function Y(t,e,n,i,r,o){return o||t.isPointInArea(e)?\"r\"!==n||i?B(t,e,n,i,r,o):$(t,e,n,r):[]}function V(t,e,n,i,r){const o=[],s=\"x\"===n?\"inXRange\":\"inYRange\";let a=!1;return j(t,n,e,((t,i,l)=>{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;t<n.length;++t)l.push({element:n[t],datasetIndex:e,index:t})}return l},point(t,e,n,r){const o=(0,i.z)(e,t),s=n.axis||\"xy\",a=n.includeInvisible||!1;return W(t,o,s,r,a)},nearest(t,e,n,r){const o=(0,i.z)(e,t),s=n.axis||\"xy\",a=n.includeInvisible||!1;return Y(t,o,s,n.intersect,r,a)},x(t,e,n,r){const o=(0,i.z)(e,t);return V(t,o,\"x\",n.intersect,r)},y(t,e,n,r){const o=(0,i.z)(e,t);return V(t,o,\"y\",n.intersect,r)}}};const q=[\"left\",\"top\",\"right\",\"bottom\"];function X(t,e){return t.filter((t=>t.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;n<i;++n)r=t[n],({position:o,options:{stack:s,stackWeight:a=1}}=r),e.push({index:n,box:r,pos:o,horizontal:r.isHorizontal(),weight:r.weight,stack:s&&o+s,stackWeight:a});return e}function J(t){const e={};for(const n of t){const{stack:t,pos:i,stackWeight:r}=n;if(!t||!q.includes(i))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=r}return e}function K(t,e){const n=J(t),{vBoxMaxWidth:i,hBoxMaxHeight:r}=e;let o,s,a;for(o=0,s=t.length;o<s;++o){a=t[o];const{fullSize:s}=a.box,l=n[a.stack],c=l&&a.stackWeight/l.weight;a.horizontal?(a.width=c?c*i:s&&e.availableWidth,a.height=r):(a.width=i,a.height=c?c*r:s&&e.availableHeight)}return n}function tt(t){const e=Q(t),n=Z(e.filter((t=>t.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<s;++o){a=t[o],l=a.box,l.update(a.width||e.w,a.height||e.h,ot(a.horizontal,e));const{same:s,other:h}=it(e,n,a,i);c|=s&&r.length,u=u||h,l.fullSize||r.push(a)}return c&&st(r,e,n,i)||u}function at(t,e,n,i,r){t.top=n,t.left=e,t.right=e+i,t.bottom=n+r,t.width=i,t.height=r}function lt(t,e,n,r){const o=n.padding;let{x:s,y:a}=e;for(const l of t){const t=l.box,c=r[l.stack]||{count:1,placed:0,weight:1},u=l.stackWeight/c.weight||1;if(l.horizontal){const r=e.w*u,s=c.size||t.height;(0,i.h)(c.start)&&(a=c.start),t.fullSize?at(t,o.left,a,n.outerWidth-o.right-o.left,s):at(t,e.left+c.placed,a,r,s),c.start=a,c.placed+=r,a=t.bottom}else{const r=e.h*u,a=c.size||t.width;(0,i.h)(c.start)&&(s=c.start),t.fullSize?at(t,s,o.top,a,n.outerHeight-o.bottom-o.top):at(t,s,e.top+c.placed,a,r),c.start=s,c.placed+=r,s=t.right}}e.x=s,e.y=a}var ct={addBox(t,e){t.boxes||(t.boxes=[]),e.fullSize=e.fullSize||!1,e.position=e.position||\"top\",e.weight=e.weight||0,e._layers=e._layers||function(){return[{z:0,draw(t){e.draw(t)}}]},t.boxes.push(e)},removeBox(t,e){const n=t.boxes?t.boxes.indexOf(e):-1;-1!==n&&t.boxes.splice(n,1)},configure(t,e,n){e.fullSize=n.fullSize,e.position=n.position,e.weight=n.weight},update(t,e,n,r){if(!t)return;const o=(0,i.E)(t.options.layout.padding),s=Math.max(e-o.width,0),a=Math.max(n-o.height,0),l=tt(t.boxes),c=l.vertical,u=l.horizontal;(0,i.F)(t.boxes,(t=>{\"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<o.clientWidth&&n()}),window),a=new ResizeObserver((t=>{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;t<n;t++)jt(e,u,h,s[t],s[t+1]);return jt(e,u,h,c,(0,i.k)(r)?e.length:c+r),u}return jt(e,u,h),u}function Lt(t){const e=t.options.offset,n=t._tickSize(),i=t._length/n+(e?0:1),r=t._maxLength/n;return Math.floor(Math.min(i,r))}function zt(t,e,n){const r=Ht(t),o=e.length/n;if(!r)return Math.max(o,1);const s=(0,i.N)(r);for(let i=0,a=s.length-1;i<a;i++){const t=s[i];if(t>o)return t}return Math.max(o,1)}function Nt(t){const e=[];let n,i;for(n=0,i=t.length;n<i;n++)t[n].major&&e.push(n);return e}function Ft(t,e,n,i){let r,o=0,s=n[0];for(i=Math.ceil(i),r=0;r<t.length;r++)r===s&&(e.push(t[r]),o++,s=n[o*i])}function jt(t,e,n,r,o){const s=(0,i.v)(r,0),a=Math.min((0,i.v)(o,t.length),t.length);let l,c,u,h=0;n=Math.ceil(n),o&&(l=o-r,n=l/Math.floor(l/n)),u=s;while(u<0)h++,u=Math.round(s+h*n);for(c=Math.max(s,0);c<a;c++)c===u&&(e.push(t[c]),h++,u=Math.round(s+h*n))}function Ht(t){const e=t.length;let n,i;if(e<2)return!1;for(i=t[0],n=1;n<e;++n)if(t[n]-t[n-1]!==i)return!1;return i}const Wt=t=>\"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(;o<r;o+=i)n.push(t[Math.floor(o)]);return n}function Vt(t,e,n){const i=t.ticks.length,r=Math.min(e,i-1),o=t._startPixel,s=t._endPixel,a=1e-6;let l,c=t.getPixelForTick(r);if(!(n&&(l=1===i?Math.max(c-o,s-c):0===e?(t.getPixelForTick(1)-c)/2:(c-t.getPixelForTick(r-1))/2,c+=r<e?l:-l,c<o-a||c>s+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;r<i;++r)delete t.data[n[r]];n.splice(0,i)}}))}function qt(t){return t.drawTicks?t.tickLength:0}function Xt(t,e){if(!t.display)return 0;const n=(0,i.a0)(t.font,e),r=(0,i.E)(t.padding),o=(0,i.b)(t.text)?t.text.length:1;return o*n.lineHeight+r.height}function Gt(t,e){return(0,i.j)(t,{scale:e,type:\"scale\"})}function Zt(t,e,n){return(0,i.j)(t,{tick:n,index:e,type:\"tick\"})}function Qt(t,e,n){let r=(0,i.a1)(t);return(n&&\"right\"!==e||!n&&\"right\"===e)&&(r=Wt(r)),r}function Jt(t,e,n,r){const{top:o,left:s,bottom:a,right:l,chart:c}=t,{chartArea:u,scales:h}=c;let d,f,p,g=0;const m=a-o,b=l-s;if(t.isHorizontal()){if(f=(0,i.a2)(r,s,l),(0,i.i)(n)){const t=Object.keys(n)[0],i=n[t];p=h[t].getPixelForValue(i)+m-e}else p=\"center\"===n?(u.bottom+u.top)/2+m-e:$t(t,n,e);d=l-s}else{if((0,i.i)(n)){const t=Object.keys(n)[0],i=n[t];f=h[t].getPixelForValue(i)-b+e}else f=\"center\"===n?(u.left+u.right)/2-b+e:$t(t,n,e);p=(0,i.a2)(r,a,o),g=\"left\"===n?-i.H:i.H}return{titleX:f,titleY:p,maxWidth:d,rotation:g}}class Kt extends Rt{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,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._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:n,_suggestedMax:r}=this;return t=(0,i.O)(t,Number.POSITIVE_INFINITY),e=(0,i.O)(e,Number.NEGATIVE_INFINITY),n=(0,i.O)(n,Number.POSITIVE_INFINITY),r=(0,i.O)(r,Number.NEGATIVE_INFINITY),{min:(0,i.O)(t,n),max:(0,i.O)(e,r),minDefined:(0,i.g)(t),maxDefined:(0,i.g)(e)}}getMinMax(t){let e,{min:n,max:r,minDefined:o,maxDefined:s}=this.getUserBounds();if(o&&s)return{min:n,max:r};const a=this.getMatchingVisibleMetas();for(let i=0,l=a.length;i<l;++i)e=a[i].controller.getMinMax(this,t),o||(n=Math.min(n,e.min)),s||(r=Math.max(r,e.max));return n=s&&n>r?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<this.ticks.length;this._convertTicksToLabels(l?Yt(this.ticks,a):this.ticks),this.configure(),this.beforeCalculateLabelRotation(),this.calculateLabelRotation(),this.afterCalculateLabelRotation(),s.display&&(s.autoSkip||\"auto\"===s.source)&&(this.ticks=It(this,this.ticks),this._labelSizes=null,this.afterAutoSkip()),l&&this._convertTicksToLabels(this.ticks),this.beforeFit(),this.fit(),this.afterFit(),this.afterUpdate()}configure(){let t,e,n=this.options.reverse;this.isHorizontal()?(t=this.left,e=this.right):(t=this.top,e=this.bottom,n=!n),this._startPixel=t,this._endPixel=e,this._reversePixels=n,this._length=e-t,this._alignToPixels=this.options.alignToPixels}afterUpdate(){(0,i.Q)(this.options.afterUpdate,[this])}beforeSetDimensions(){(0,i.Q)(this.options.beforeSetDimensions,[this])}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=0,this.right=this.width):(this.height=this.maxHeight,this.top=0,this.bottom=this.height),this.paddingLeft=0,this.paddingTop=0,this.paddingRight=0,this.paddingBottom=0}afterSetDimensions(){(0,i.Q)(this.options.afterSetDimensions,[this])}_callHooks(t){this.chart.notifyPlugins(t,this.getContext()),(0,i.Q)(this.options[t],[this])}beforeDataLimits(){this._callHooks(\"beforeDataLimits\")}determineDataLimits(){}afterDataLimits(){this._callHooks(\"afterDataLimits\")}beforeBuildTicks(){this._callHooks(\"beforeBuildTicks\")}buildTicks(){return[]}afterBuildTicks(){this._callHooks(\"afterBuildTicks\")}beforeTickToLabelConversion(){(0,i.Q)(this.options.beforeTickToLabelConversion,[this])}generateTickLabels(t){const e=this.options.ticks;let n,r,o;for(n=0,r=t.length;n<r;n++)o=t[n],o.label=(0,i.Q)(e.callback,[o.value,n,t],this)}afterTickToLabelConversion(){(0,i.Q)(this.options.afterTickToLabelConversion,[this])}beforeCalculateLabelRotation(){(0,i.Q)(this.options.beforeCalculateLabelRotation,[this])}calculateLabelRotation(){const t=this.options,e=t.ticks,n=Bt(this.ticks.length,t.ticks.maxTicksLimit),r=e.minRotation||0,o=e.maxRotation;let s,a,l,c=r;if(!this._isVisible()||!e.display||r>=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<n;e++)(0,i.k)(t[e].label)&&(t.splice(e,1),n--,e--);this.afterTickToLabelConversion()}_getLabelSizes(){let t=this._labelSizes;if(!t){const e=this.options.ticks.sampleSize;let n=this.ticks;e<n.length&&(n=Yt(n,e)),this._labelSizes=t=this._computeLabelSizes(n,n.length,this.options.ticks.maxTicksLimit)}return t}_computeLabelSizes(t,e,n){const{ctx:r,_longestTextCache:o}=this,s=[],a=[],l=Math.floor(e/Bt(e,n));let c,u,h,d,f,p,g,m,b,x,y,v=0,w=0;for(c=0;c<e;c+=l){if(d=t[c].label,f=this._resolveTickFontOptions(c),r.font=p=f.string,g=o[p]=o[p]||{data:{},gc:[]},m=f.lineHeight,b=x=0,(0,i.k)(d)||(0,i.b)(d)){if((0,i.b)(d))for(u=0,h=d.length;u<h;++u)y=d[u],(0,i.k)(y)||(0,i.b)(y)||(b=(0,i.V)(r,g.data,g.gc,b,y),x+=m)}else b=(0,i.V)(r,g.data,g.gc,b,d),x=m;s.push(b),a.push(x),v=Math.max(b,v),w=Math.max(x,w)}Ut(o,e);const k=s.indexOf(v),_=a.indexOf(w),M=t=>({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&&t<e.length){const n=e[t];return n.$context||(n.$context=Zt(this.getContext(),t,n))}return this.$context||(this.$context=Gt(this.chart.getContext(),this))}_tickSize(){const t=this.options.ticks,e=(0,i.t)(this.labelRotation),n=Math.abs(Math.cos(e)),r=Math.abs(Math.sin(e)),o=this._getLabelSizes(),s=t.autoSkipPadding||0,a=o?o.widest.width+s:0,l=o?o.highest.height+s:0;return this.isHorizontal()?l*n>a*r?a/n:l/r:l*r<a*n?l/n:a/r}_isVisible(){const t=this.options.display;return\"auto\"!==t?!!t:this.getMatchingVisibleMetas().length>0}_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;y<h;y+=P){const t=this.getContext(y),e=o.setContext(t),r=a.setContext(t),s=e.lineWidth,u=e.color,h=r.dash||[],d=r.dashOffset,p=e.tickWidth,g=e.tickColor,m=e.tickBorderDash||[],b=e.tickBorderDashOffset;v=Vt(this,y,l),void 0!==v&&(w=(0,i.X)(n,v,s),c?k=M=T=C=w:_=S=D=A=w,f.push({tx1:k,ty1:_,tx2:M,ty2:S,x1:T,y1:D,x2:C,y2:A,width:s,color:u,borderDash:h,borderDashOffset:d,tickWidth:p,tickColor:g,tickBorderDash:m,tickBorderDashOffset:b}))}return this._ticksLength=h,this._borderValue=x,f}_computeLabelItems(t){const e=this.axis,n=this.options,{position:r,ticks:o}=n,s=this.isHorizontal(),a=this.ticks,{align:l,crossAlign:c,padding:u,mirror:h}=o,d=qt(n.grid),f=d+u,p=h?-u:f,g=-(0,i.t)(this.labelRotation),m=[];let b,x,y,v,w,k,_,M,S,T,D,C,A=\"middle\";if(\"top\"===r)k=this.bottom-p,_=this._getXAxisLabelAlignment();else if(\"bottom\"===r)k=this.top+p,_=this._getXAxisLabelAlignment();else if(\"left\"===r){const t=this._getYAxisLabelAlignment(d);_=t.textAlign,w=t.x}else if(\"right\"===r){const t=this._getYAxisLabelAlignment(d);_=t.textAlign,w=t.x}else if(\"x\"===e){if(\"center\"===r)k=(t.top+t.bottom)/2+f;else if((0,i.i)(r)){const t=Object.keys(r)[0],e=r[t];k=this.chart.scales[t].getPixelForValue(e)+f}_=this._getXAxisLabelAlignment()}else if(\"y\"===e){if(\"center\"===r)w=(t.left+t.right)/2-f;else if((0,i.i)(r)){const t=Object.keys(r)[0],e=r[t];w=this.chart.scales[t].getPixelForValue(e)}_=this._getYAxisLabelAlignment(d).textAlign}\"y\"===e&&(\"start\"===l?A=\"top\":\"end\"===l&&(A=\"bottom\"));const O=this._getLabelSizes();for(b=0,x=a.length;b<x;++b){y=a[b],v=y.label;const t=o.setContext(this.getContext(b));M=this.getPixelForTick(b)+o.labelOffset,S=this._resolveTickFontOptions(b),T=S.lineHeight,D=(0,i.b)(v)?v.length:1;const e=D/2,n=t.color,l=t.textStrokeColor,u=t.textStrokeWidth;let d,f=_;if(s?(w=M,\"inner\"===_&&(f=b===x-1?this.options.reverse?\"left\":\"right\":0===b?this.options.reverse?\"right\":\"left\":\"center\"),C=\"top\"===r?\"near\"===c||0!==g?-D*T+T/2:\"center\"===c?-O.highest.height/2-e*T+T:-O.highest.height+T/2:\"near\"===c||0!==g?T/2:\"center\"===c?O.highest.height/2-e*T:O.highest.height-D*T,h&&(C*=-1),0===g||t.showLabelBackdrop||(w+=T/2*Math.sin(g))):(k=M,C=(1-D)*T/2),t.showLabelBackdrop){const e=(0,i.E)(t.backdropPadding),n=O.heights[b],r=O.widths[b];let o=C-e.top,s=0-e.left;switch(A){case\"middle\":o-=n/2;break;case\"bottom\":o-=n;break}switch(_){case\"center\":s-=r/2;break;case\"right\":s-=r;break;case\"inner\":b===x-1?s-=r:b>0&&(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<o;++r){const t=i[r];e.drawOnChartArea&&s({x:t.x1,y:t.y1},{x:t.x2,y:t.y2},t),e.drawTicks&&s({x:t.tx1,y:t.ty1},{x:t.tx2,y:t.ty2},{color:t.tickColor,width:t.tickWidth,borderDash:t.tickBorderDash,borderDashOffset:t.tickBorderDashOffset})}}drawBorder(){const{chart:t,ctx:e,options:{border:n,grid:r}}=this,o=n.setContext(this.getContext()),s=n.display?o.width:0;if(!s)return;const a=r.setContext(this.getContext(0)).lineWidth,l=this._borderValue;let c,u,h,d;this.isHorizontal()?(c=(0,i.X)(t,this.left,s)-s/2,u=(0,i.X)(t,this.right,a)+a/2,h=d=l):(h=(0,i.X)(t,this.top,s)-s/2,d=(0,i.X)(t,this.bottom,a)+a/2,c=u=l),e.save(),e.lineWidth=o.width,e.strokeStyle=o.color,e.beginPath(),e.moveTo(c,h),e.lineTo(u,d),e.stroke(),e.restore()}drawLabels(t){const e=this.options.ticks;if(!e.display)return;const n=this.ctx,r=this._computeLabelArea();r&&(0,i.Y)(n,r);const o=this.getLabelItems(t);for(const s of o){const t=s.options,e=s.font,r=s.label,o=s.textOffset;(0,i.Z)(n,r,0,o,e,t)}r&&(0,i.$)(n)}drawTitle(){const{ctx:t,options:{position:e,title:n,reverse:r}}=this;if(!n.display)return;const o=(0,i.a0)(n.font),s=(0,i.E)(n.padding),a=n.align;let l=o.lineHeight/2;\"bottom\"===e||\"center\"===e||(0,i.i)(e)?(l+=s.bottom,(0,i.b)(n.text)&&(l+=o.lineHeight*(n.text.length-1))):l+=s.top;const{titleX:c,titleY:u,maxWidth:h,rotation:d}=Jt(this,l,e,a);(0,i.Z)(t,n.text,0,0,o,{color:n.color,maxWidth:h,rotation:d,textAlign:Qt(a,e,r),textBaseline:\"middle\",translation:[c,u]})}draw(t){this._isVisible()&&(this.drawBackground(),this.drawGrid(t),this.drawBorder(),this.drawTitle(),this.drawLabels(t))}_layers(){const t=this.options,e=t.ticks&&t.ticks.z||0,n=(0,i.v)(t.grid&&t.grid.z,-1),r=(0,i.v)(t.border&&t.border.z,0);return this._isVisible()&&this.draw===Kt.prototype.draw?[{z:n,draw:t=>{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<o;++r){const o=e[r];o[n]!==this.id||t&&o.type!==t||i.push(o)}return i}_resolveTickFontOptions(t){const e=this.options.ticks.setContext(this.getContext(t));return(0,i.a0)(e.font)}_maxDigits(){const t=this._resolveTickFontOptions(0).lineHeight;return(this.isHorizontal()?this.width:this.height)/t}}class te{constructor(t,e,n){this.type=t,this.scope=e,this.override=n,this.items=Object.create(null)}isForType(t){return Object.prototype.isPrototypeOf.call(this.type.prototype,t.prototype)}register(t){const e=Object.getPrototypeOf(t);let n;ie(e)&&(n=this.register(e));const r=this.items,o=t.id,s=this.scope+\".\"+o;if(!o)throw new Error(\"class does not have id: \"+t);return o in r||(r[o]=t,ee(t,s,n),this.override&&i.d.override(t.id,t.overrides)),s}get(t){return this.items[t]}unregister(t){const e=this.items,n=t.id,r=this.scope;n in e&&delete e[n],r&&n in i.d[r]&&(delete i.d[r][n],this.override&&delete i.a3[n])}}function ee(t,e,n){const r=(0,i.a4)(Object.create(null),[n?i.d.get(n):{},i.d.get(e),t.defaults]);i.d.set(e,r),t.defaultRoutes&&ne(e,t.defaultRoutes),t.descriptors&&i.d.describe(e,t.descriptors)}function ne(t,e){Object.keys(e).forEach((n=>{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;e<this._typedRegistries.length;e++){const n=this._typedRegistries[e];if(n.isForType(t))return n}return this.plugins}_get(t,e,n){const i=e.get(t);if(void 0===i)throw new Error('\"'+t+'\" is not a registered '+n+\".\");return i}}var oe=new re;class se{constructor(){this._init=void 0}notify(t,e,n,i){if(\"beforeInit\"===e&&(this._init=this._createDescriptors(t,!0),this._notify(this._init,t,\"install\")),void 0===this._init)return;const r=i?this._descriptors(t).filter(i):this._descriptors(t),o=this._notify(r,t,e,n);return\"afterDestroy\"===e&&(this._notify(r,t,\"stop\"),this._notify(this._init,t,\"uninstall\"),this._init=void 0),o}_notify(t,e,n,r){r=r||{};for(const o of t){const t=o.plugin,s=t[n],a=[e,r,o.options];if(!1===(0,i.Q)(s,a,t)&&r.cancelable)return!1}return!0}invalidate(){(0,i.k)(this._cache)||(this._oldCache=this._cache,this._cache=void 0)}_descriptors(t){if(this._cache)return this._cache;const e=this._cache=this._createDescriptors(t);return this._notifyStateChanges(t),e}_createDescriptors(t,e){const n=t&&t.config,r=(0,i.v)(n.options&&n.options.plugins,{}),o=ae(n);return!1!==r||e?ce(t,o,r,e):[]}_notifyStateChanges(t){const e=this._oldCache||[],n=this._cache,i=(t,e)=>t.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;o<i.length;o++)n.push(oe.getPlugin(i[o]));const r=t.plugins||[];for(let o=0;o<r.length;o++){const t=r[o];-1===n.indexOf(t)&&(n.push(t),e[t.id]=!0)}return{plugins:n,localIds:e}}function le(t,e){return e||!1!==t?!0===t?{}:t:null}function ce(t,{plugins:e,localIds:n},i,r){const o=[],s=t.getContext();for(const a of e){const e=a.id,l=le(i[e],r);null!==l&&o.push({plugin:a,options:ue(t.config,{plugin:a,local:n[e]},l,s)})}return o}function ue(t,{plugin:e,local:n},i,r){const o=t.pluginScopeKeys(e),s=t.getOptionScopes(i,o);return n&&e.defaults&&s.push(e.defaults),t.createResolver(s,r,[\"\"],{scriptable:!1,indexable:!1,allKeys:!0})}function he(t,e){const n=i.d.datasets[t]||{},r=(e.datasets||{})[t]||{};return r.indexAxis||e.indexAxis||n.indexAxis||\"x\"}function de(t,e){let n=t;return\"_index_\"===t?n=e:\"_value_\"===t&&(n=\"x\"===e?\"y\":\"x\"),n}function fe(t,e){return t===e?\"_index_\":\"_value_\"}function pe(t){if(\"x\"===t||\"y\"===t||\"r\"===t)return t}function ge(t){return\"top\"===t||\"bottom\"===t?\"x\":\"left\"===t||\"right\"===t?\"y\":void 0}function me(t,...e){if(pe(t))return t;for(const n of e){const e=n.axis||ge(n.position)||t.length>1&&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;t<n;++t)this._destroyDatasetMeta(t);t.splice(e,n-e)}this._sortedMetasets=t.slice(0).sort(Ie(\"order\",\"index\"))}_removeUnreferencedMetasets(){const{_metasets:t,data:{datasets:e}}=this;t.length>e.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<r;n++){const r=e[n];let o=this.getDatasetMeta(n);const s=r.type||this.config.type;if(o.type&&o.type!==s&&(this._destroyDatasetMeta(n),o=this.getDatasetMeta(n)),o.type=s,o.indexAxis=r.indexAxis||he(s,this.options),o.order=r.order||0,o.index=n,o.label=\"\"+r.label,o.visible=this.isDatasetVisible(n),o.controller)o.controller.updateIndex(n),o.controller.linkScales();else{const e=oe.getController(s),{datasetElementType:r,dataElementType:a}=i.d.datasets[s];Object.assign(e,{dataElementType:oe.getElement(a),datasetElementType:r&&oe.getElement(r)}),o.controller=new e(this,n),t.push(o.controller)}}return this._updateMetasets(),t}_resetElements(){(0,i.F)(this.data.datasets,((t,e)=>{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<c;i++){const{controller:t}=this.getDatasetMeta(i),e=!r&&-1===o.indexOf(t);t.buildOrUpdateElements(e),s=Math.max(+t.getMaxOverflow(),s)}s=this._minPadding=n.layout.autoPadding?s:0,this._updateLayout(s),r||(0,i.F)(o,(t=>{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;o<e;o++)if(!(0,i.ag)(r,n(o)))return;return Array.from(r).map((t=>t.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<e;++t)this.getDatasetMeta(t).controller.configure();for(let e=0,n=this.data.datasets.length;e<n;++e)this._updateDataset(e,(0,i.a7)(t)?t({datasetIndex:e}):t);this.notifyPlugins(\"afterDatasetsUpdate\",{mode:t})}}_updateDataset(t,e){const n=this.getDatasetMeta(t),i={meta:n,index:t,mode:e,cancelable:!0};!1!==this.notifyPlugins(\"beforeDatasetUpdate\",i)&&(n.controller._update(e),i.cancelable=!1,this.notifyPlugins(\"afterDatasetUpdate\",i))}render(){!1!==this.notifyPlugins(\"beforeRender\",{cancelable:!0})&&(o.has(this)?this.attached&&!o.running(this)&&o.start(this):(this.draw(),Le({chart:this})))}draw(){let t;if(this._resizeBeforeDraw){const{width:t,height:e}=this._resizeBeforeDraw;this._resizeBeforeDraw=null,this._resize(t,e)}if(this.clear(),this.width<=0||this.height<=0)return;if(!1===this.notifyPlugins(\"beforeDraw\",{cancelable:!0}))return;const e=this._layers;for(t=0;t<e.length&&e[t].z<=0;++t)e[t].draw(this.chartArea);for(this._drawDatasets();t<e.length;++t)e[t].draw(this.chartArea);this.notifyPlugins(\"afterDraw\")}_getSortedDatasetMetas(t){const e=this._sortedMetasets,n=[];let i,r;for(i=0,r=e.length;i<r;++i){const r=e[i];t&&!r.visible||n.push(r)}return n}getSortedVisibleDatasetMetas(){return this._getSortedDatasetMetas(!0)}_drawDatasets(){if(!1===this.notifyPlugins(\"beforeDatasetsDraw\",{cancelable:!0}))return;const t=this.getSortedVisibleDatasetMetas();for(let e=t.length-1;e>=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;++t)this._destroyDatasetMeta(t)}destroy(){this.notifyPlugins(\"beforeDestroy\");const{canvas:t,ctx:e}=this;this._stop(),this.config.clearCache(),t&&(this.unbindEvents(),(0,i.af)(t,e),this.platform.releaseContext(e),this.canvas=null,this.ctx=null),delete Fe[this.id],this.notifyPlugins(\"afterDestroy\")}toBase64Image(...t){return this.canvas.toDataURL(...t)}bindEvents(){this.bindUserEvents(),this.options.responsive?this.bindResponsiveEvents():this.attached=!0}bindUserEvents(){const t=this._listeners,e=this.platform,n=(n,i)=>{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<a;++s){o=t[s];const e=o&&this.getDatasetMeta(o.datasetIndex).controller;e&&e[i+\"HoverStyle\"](o.element,o.datasetIndex,o.index)}}getActiveElements(){return this._active||[]}setActiveElements(t){const e=this._active||[],n=t.map((({datasetIndex:t,index:e})=>{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=r<s&&o<s||r>a&&o>a;return{count:i,start:l,loop:e.loop,ilen:c<l&&!u?i+c-l:c-l}}function Xe(t,e,n,i){const{points:r,options:o}=e,{count:s,start:a,loop:l,ilen:c}=qe(r,n,i),u=Ue(o);let h,d,f,{move:p=!0,reverse:g}=i||{};for(h=0;h<=c;++h)d=r[(a+(g?c-h:h))%s],d.skip||(p?(t.moveTo(d.x,d.y),p=!1):u(t,f,d,g,o.stepped),f=d);return l&&(d=r[(a+(g?c:0))%s],u(t,f,d,g,o.stepped)),!!l}function Ge(t,e,n,i){const r=e.points,{count:o,start:s,ilen:a}=qe(r,n,i),{move:l=!0,reverse:c}=i||{};let u,h,d,f,p,g,m=0,b=0;const x=t=>(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?(n<f?f=n:n>p&&(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<u;++c){const{start:i,end:u}=s[c],h=o[i],d=o[u];if(h===d){a.push(h);continue}const f=Math.abs((r-h[e])/(d[e]-h[e])),p=l(h,d,f,n.stepped);p[e]=t[e],a.push(p)}return 1===a.length?a[0]:a}pathSegment(t,e,n){const i=Ze(this);return i(t,this,e,n)}path(t,e,n){const i=this.segments,r=Ze(this);let o=this._loop;e=e||0,n=n||this.points.length-e;for(const s of i)o&=r(t,this,s,{start:e,end:e+n-1});return!!o}draw(t,e,n,i){const r=this.options||{},o=this.points||[];o.length&&r.borderWidth&&(t.save(),en(t,this,n,i),t.restore()),this.animated&&(this._pointsUpdated=!1,this._path=void 0)}}function rn(t,e,n,i){const r=t.options,{[n]:o}=t.getProps([n],i);return Math.abs(e-o)<r.radius+r.hitRadius}class on extends Rt{static id=\"point\";parsed;skip;stop;static defaults={borderWidth:1,hitRadius:1,hoverBorderWidth:1,hoverRadius:4,pointStyle:\"circle\",radius:3,rotation:0};static defaultRoutes={backgroundColor:\"backgroundColor\",borderColor:\"borderColor\"};constructor(t){super(),this.options=void 0,this.parsed=void 0,this.skip=void 0,this.stop=void 0,t&&Object.assign(this,t)}inRange(t,e,n){const i=this.options,{x:r,y:o}=this.getProps([\"x\",\"y\"],n);return Math.pow(t-r,2)+Math.pow(e-o,2)<Math.pow(i.hitRadius+i.radius,2)}inXRange(t,e){return rn(this,t,\"x\",e)}inYRange(t,e){return rn(this,t,\"y\",e)}getCenterPoint(t){const{x:e,y:n}=this.getProps([\"x\",\"y\"],t);return{x:e,y:n}}size(t){t=t||this.options||{};let e=t.radius||0;e=Math.max(e,e&&t.hoverRadius||0);const n=e&&t.borderWidth||0;return 2*(e+n)}draw(t,e){const n=this.options;this.skip||n.radius<.1||!(0,i.C)(this,e,this.size(n)/2)||(t.strokeStyle=n.borderColor,t.lineWidth=n.borderWidth,t.fillStyle=n.backgroundColor,(0,i.av)(t,n,this.x,this.y))}getRange(){const t=this.options||{};return t.radius+t.hitRadius}}function sn(t,e,n){const r=t.segments,o=t.points,s=e.points,a=[];for(const l of r){let{start:t,end:r}=l;r=cn(t,r,o);const c=an(n,o[t],o[r],l.loop);if(!e.segments){a.push({source:l,target:c,start:o[t],end:o[r]});continue}const u=(0,i.ap)(e,c);for(const e of u){const t=an(n,s[e.start],s[e.end],e.loop),r=(0,i.az)(l,o,t);for(const i of r)a.push({source:i,target:e,start:{[n]:un(c,t,\"start\",Math.max)},end:{[n]:un(c,t,\"end\",Math.min)}})}}return a}function an(t,e,n,r){if(r)return;let o=e[t],s=n[t];return\"angle\"===t&&(o=(0,i.al)(o),s=(0,i.al)(s)),{property:t,start:o,end:s}}function ln(t,e){const{x:n=null,y:i=null}=t||{},r=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{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<o.length;l++){const t=o[l];for(let e=t.start;e<=t.end;e++)wn(r,s[e],a)}return new nn({points:r,options:{}})}function vn(t,e){const n=[],i=t.getMatchingVisibleMetas(\"line\");for(let r=0;r<i.length;r++){const t=i[r];if(t.index===e)break;t.hidden||n.unshift(t.dataset)}return n}function wn(t,e,n){const i=[];for(let r=0;r<n.length;r++){const o=n[r],{first:s,last:a,point:l}=kn(o,e,\"x\");if(!(!l||s&&a))if(s)i.unshift(l);else if(t.push(l),!a)break}t.push(...i)}function kn(t,e,n){const r=t.interpolate(e,n);if(!r)return{};const o=r[n],s=t.segments,a=t.points;let l=!1,c=!1;for(let u=0;u<s.length;u++){const t=s[u],e=a[t.start][n],r=a[t.end][n];if((0,i.ak)(o,e,r)){l=o===e,c=o===r;break}}return{first:l,last:c,point:r}}class _n{constructor(t){this.x=t.x,this.y=t.y,this.radius=t.radius}pathSegment(t,e,n){const{x:r,y:o,radius:s}=this;return e=e||{start:0,end:i.T},t.arc(r,o,s,e.end,e.start,!0),!n.bounds}interpolate(t){const{x:e,y:n,radius:i}=this,r=t.angle;return{x:e+Math.cos(r)*i,y:n+Math.sin(r)*i,angle:r}}}function Mn(t){const{chart:e,fill:n,line:r}=t;if((0,i.g)(n))return Sn(e,n);if(\"stack\"===n)return yn(t);if(\"shape\"===n)return!0;const o=Tn(t);return o instanceof _n?o:hn(o,r)}function Sn(t,e){const n=t.getDatasetMeta(e),i=n&&t.isDatasetVisible(e);return i?n.dataset:null}function Tn(t){const e=t.scale||{};return e.getPointPositionForValue?Cn(t):Dn(t)}function Dn(t){const{scale:e={},fill:n}=t,r=mn(n,e);if((0,i.g)(r)){const t=e.isHorizontal();return{x:t?r:null,y:t?null:r}}return null}function Cn(t){const{scale:e,fill:n}=t,i=e.options,r=e.getLabels().length,o=i.reverse?e.max:e.min,s=bn(n,e,o),a=[];if(i.grid.circular){const t=e.getPointPositionForValue(0,o);return new _n({x:t.x,y:t.y,radius:e.getDistanceFromCenterForValue(s)})}for(let l=0;l<r;++l)a.push(e.getPointPositionForValue(l,s));return a}function An(t,e,n){const r=Mn(e),{chart:o,index:s,line:a,scale:l,axis:c}=e,u=a.options,h=u.fill,d=u.backgroundColor,{above:f=d,below:p=d}=h||{},g=o.getDatasetMeta(s),m=(0,i.ah)(o,g);r&&a.points.length&&((0,i.Y)(t,n),On(t,{line:a,target:r,above:f,below:p,area:n,scale:l,axis:c,clip:m}),(0,i.$)(t))}function On(t,e){const{line:n,target:i,above:r,below:o,area:s,scale:a,clip:l}=e,c=n._loop?\"angle\":e.axis;t.save();let u=o;o!==r&&(\"x\"===c?(Pn(t,i,s.top),Rn(t,{line:n,target:i,color:r,scale:a,property:c,clip:l}),t.restore(),t.save(),Pn(t,i,s.bottom)):\"y\"===c&&(En(t,i,s.left),Rn(t,{line:n,target:i,color:o,scale:a,property:c,clip:l}),t.restore(),t.save(),En(t,i,s.right),u=r)),Rn(t,{line:n,target:i,color:u,scale:a,property:c,clip:l}),t.restore()}function Pn(t,e,n){const{segments:i,points:r}=e;let o=!0,s=!1;t.beginPath();for(const a of i){const{start:i,end:l}=a,c=r[i],u=r[cn(i,l,r)];o?(t.moveTo(c.x,c.y),o=!1):(t.lineTo(c.x,n),t.lineTo(c.x,c.y)),s=!!e.pathSegment(t,a,{move:s}),s?t.closePath():t.lineTo(u.x,n)}t.lineTo(e.first().x,n),t.closePath(),t.clip()}function En(t,e,n){const{segments:i,points:r}=e;let o=!0,s=!1;t.beginPath();for(const a of i){const{start:i,end:l}=a,c=r[i],u=r[cn(i,l,r)];o?(t.moveTo(c.x,c.y),o=!1):(t.lineTo(n,c.y),t.lineTo(c.x,c.y)),s=!!e.pathSegment(t,a,{move:s}),s?t.closePath():t.lineTo(n,u.y)}t.lineTo(n,e.first().y),t.closePath(),t.clip()}function Rn(t,e){const{line:n,target:i,property:r,color:o,scale:s,clip:a}=e,l=sn(n,i,r);for(const{source:c,target:u,start:h,end:d}of l){const{style:{backgroundColor:e=o}={}}=c,l=!0!==i;t.save(),t.fillStyle=e,In(t,s,a,l&&an(r,h,d)),t.beginPath();const f=!!n.pathSegment(t,c);let p;if(l){f?t.closePath():Ln(t,i,d,r);const e=!!i.pathSegment(t,u,{move:f,reverse:!0});p=f&&e,p||Ln(t,i,h,r)}t.closePath(),t.fill(p?\"evenodd\":\"nonzero\"),t.restore()}}function In(t,e,n,i){const r=e.chart.chartArea,{property:o,start:s,end:a}=i||{};if(\"x\"===o||\"y\"===o){let e,i,l,c;\"x\"===o?(e=s,i=r.top,l=a,c=r.bottom):(e=r.left,i=s,l=r.right,c=a),t.beginPath(),n&&(e=Math.max(e,n.left),l=Math.min(l,n.right),i=Math.max(i,n.top),c=Math.min(c,n.bottom)),t.rect(e,i,l-e,c-i),t.clip()}}function Ln(t,e,n,i){const r=e.interpolate(n,i);r&&t.lineTo(r.x,r.y)}var zn={id:\"filler\",afterDatasetsUpdate(t,e,n){const i=(t.data.datasets||[]).length,r=[];let o,s,a,l;for(s=0;s<i;++s)o=t.getDatasetMeta(s),a=o.dataset,l=null,a&&a.options&&a instanceof nn&&(l={visible:t.isDatasetVisible(s),index:s,fill:pn(a,s,i),chart:t,axis:o.controller.options.indexAxis,scale:o.vScale,line:a}),o.$filler=l,r.push(l);for(s=0;s<i;++s)l=r[s],l&&!1!==l.fill&&(l.fill=fn(r,s,n.propagate))},beforeDraw(t,e,n){const i=\"beforeDraw\"===n.drawTime,r=t.getSortedVisibleDatasetMetas(),o=t.chartArea;for(let s=r.length-1;s>=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;n<o.length;++n)if(r=o[n],(0,i.ak)(t,r.left,r.left+r.width)&&(0,i.ak)(e,r.top,r.top+r.height))return this.legendItems[n];return null}handleEvent(t){const e=this.options;if(!Yn(t.type,e))return;const n=this._getLegendItemAt(t.x,t.y);if(\"mousemove\"===t.type||\"mouseout\"===t.type){const r=this._hoveredItem,o=Fn(r,n);r&&!o&&(0,i.Q)(e.onLeave,[t,r,this],this),this._hoveredItem=n,n&&!o&&(0,i.Q)(e.onHover,[t,n,this],this)}else n&&(0,i.Q)(e.onClick,[t,n,this],this)}}function Hn(t,e,n,i,r){const o=Wn(i,t,e,n),s=$n(r,i,e.lineHeight);return{itemWidth:o,itemHeight:s}}function Wn(t,e,n,i){let r=t.text;return r&&\"string\"!==typeof r&&(r=r.reduce(((t,e)=>t.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;e<n;++e){const n=t[e].element;if(n&&n.hasValue()){const t=n.tooltipPosition();i.add(t.x),r+=t.y,++o}}if(0===o||0===i.size)return!1;const s=[...i].reduce(((t,e)=>t+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<r;++n){const r=t[n].element;if(r&&r.hasValue()){const t=r.getCenterPoint(),n=(0,i.aF)(e,t);n<l&&(l=n,o=r)}}if(o){const t=o.tooltipPosition();s=t.x,a=t.y}return{x:s,y:a}}};function Zn(t,e){return e&&((0,i.b)(e)?Array.prototype.push.apply(t,e):t.push(e)),t}function Qn(t){return(\"string\"===typeof t||t instanceof String)&&t.indexOf(\"\\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 n<i/2?\"top\":n>t.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<i)return n[e.dataIndex]}return\"\"},afterTitle:i.aG,beforeBody:i.aG,beforeLabel:i.aG,label(t){if(this&&this.options&&\"dataset\"===this.options.mode)return t.label+\": \"+t.formattedValue||t.formattedValue;let e=t.dataset.label||\"\";e&&(e+=\": \");const n=t.formattedValue;return(0,i.k)(n)||(e+=n),e},labelColor(t){const e=t.chart.getDatasetMeta(t.datasetIndex),n=e.controller.getStyle(t.dataIndex);return{borderColor:n.borderColor,backgroundColor:n.backgroundColor,borderWidth:n.borderWidth,borderDash:n.borderDash,borderDashOffset:n.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(t){const e=t.chart.getDatasetMeta(t.datasetIndex),n=e.controller.getStyle(t.dataIndex);return{pointStyle:n.pointStyle,rotation:n.rotation}},afterLabel:i.aG,afterBody:i.aG,beforeFooter:i.aG,footer:i.aG,afterFooter:i.aG};function di(t,e,n,i){const r=t[e].call(n,i);return\"undefined\"===typeof r?hi[e].call(n,i):r}class fi extends Rt{static positioners=Gn;constructor(t){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=t.chart,this.options=t.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(t){this.options=t,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){const t=this._cachedAnimations;if(t)return t;const e=this.chart,n=this.options.setContext(this.getContext()),i=n.enabled&&e.options.animation&&n.animations,r=new c(this.chart,i);return i._cacheable&&(this._cachedAnimations=Object.freeze(r)),r}getContext(){return this.$context||(this.$context=ci(this.chart.getContext(),this,this._tooltipItems))}getTitle(t,e){const{callbacks:n}=e,i=di(n,\"beforeTitle\",this,t),r=di(n,\"title\",this,t),o=di(n,\"afterTitle\",this,t);let s=[];return s=Zn(s,Qn(i)),s=Zn(s,Qn(r)),s=Zn(s,Qn(o)),s}getBeforeBody(t,e){return li(di(e.callbacks,\"beforeBody\",this,t))}getBody(t,e){const{callbacks:n}=e,r=[];return(0,i.F)(t,(t=>{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;a<l;++a)c.push(Jn(this.chart,e[a]));return t.filter&&(c=c.filter(((e,i,r)=>t.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;l<o;++l)e.fillText(r[l],c.x(t.x),t.y+s.lineHeight/2),t.y+=s.lineHeight+a,l+1===o&&(t.y+=n.titleMarginBottom-a)}}_drawColorBox(t,e,n,r,o){const s=this.labelColors[n],a=this.labelPointStyles[n],{boxHeight:l,boxWidth:c}=o,u=(0,i.a0)(o.bodyFont),h=ai(this,\"left\",o),d=r.x(h),f=l<u.lineHeight?(u.lineHeight-l)/2:0,p=e.y+f;if(o.usePointStyle){const e={radius:Math.min(c,l)/2,pointStyle:a.pointStyle,rotation:a.rotation,borderWidth:1},n=r.leftForLtr(d,c)+c/2,u=p+l/2;t.strokeStyle=o.multiKeyBackground,t.fillStyle=o.multiKeyBackground,(0,i.av)(t,e,n,u),t.strokeStyle=s.borderColor,t.fillStyle=s.backgroundColor,(0,i.av)(t,e,n,u)}else{t.lineWidth=(0,i.i)(s.borderWidth)?Math.max(...Object.values(s.borderWidth)):s.borderWidth||1,t.strokeStyle=s.borderColor,t.setLineDash(s.borderDash||[]),t.lineDashOffset=s.borderDashOffset||0;const e=r.leftForLtr(d,c),n=r.leftForLtr(r.xPlus(d,1),c-2),a=(0,i.ay)(s.borderRadius);Object.values(a).some((t=>0!==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;v<k;++v){for(b=r[v],x=this.labelTextColors[v],e.fillStyle=x,(0,i.F)(b.before,g),y=b.lines,a&&y.length&&(this._drawColorBox(e,t,v,p,n),d=Math.max(h.lineHeight,l)),w=0,_=y.length;w<_;++w)g(y[w]),d=h.lineHeight;(0,i.F)(b.after,g)}f=0,d=h.lineHeight,(0,i.F)(this.afterBody,g),t.y-=o}drawFooter(t,e,n){const r=this.footer,o=r.length;let s,a;if(o){const l=(0,i.aA)(n.rtl,this.x,this.width);for(t.x=ai(this,n.footerAlign,n),t.y+=n.footerMarginTop,e.textAlign=l.textAlign(n.footerAlign),e.textBaseline=\"middle\",s=(0,i.a0)(n.footerFont),e.fillStyle=n.footerColor,e.font=s.string,a=0;a<o;++a)e.fillText(r[a],l.x(t.x),t.y+s.lineHeight/2),t.y+=s.lineHeight+n.footerSpacing}}drawBackground(t,e,n,r){const{xAlign:o,yAlign:s}=this,{x:a,y:l}=t,{width:c,height:u}=n,{topLeft:h,topRight:d,bottomLeft:f,bottomRight:p}=(0,i.ay)(r.cornerRadius);e.fillStyle=r.backgroundColor,e.strokeStyle=r.borderColor,e.lineWidth=r.borderWidth,e.beginPath(),e.moveTo(a+h,l),\"top\"===s&&this.drawCaret(t,e,n,r),e.lineTo(a+c-d,l),e.quadraticCurveTo(a+c,l,a+c,l+d),\"center\"===s&&\"right\"===o&&this.drawCaret(t,e,n,r),e.lineTo(a+c,l+u-p),e.quadraticCurveTo(a+c,l+u,a+c-p,l+u),\"bottom\"===s&&this.drawCaret(t,e,n,r),e.lineTo(a+f,l+u),e.quadraticCurveTo(a,l+u,a,l+u-f),\"center\"===s&&\"left\"===o&&this.drawCaret(t,e,n,r),e.lineTo(a,l+h),e.quadraticCurveTo(a,l,a+h,l),e.closePath(),e.fill(),r.borderWidth>0&&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&&t<e.length?e[t]:t}class yi extends Kt{static id=\"category\";static defaults={ticks:{callback:xi}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:n,label:i}of e)t[n]===i&&t.splice(n,1);this._addedLabels=[]}super.init(t)}parse(t,e){if((0,i.k)(t))return null;const n=this.getLabels();return e=isFinite(e)&&n[e]===t?e:mi(n,t,(0,i.v)(e,t),this._addedLabels),bi(e,n.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:n,max:i}=this.getMinMax(!0);\"ticks\"===this.options.bounds&&(t||(n=0),e||(i=this.getLabels().length-1)),this.min=n,this.max=i}buildTicks(){const t=this.min,e=this.max,n=this.options.offset,i=[];let r=this.getLabels();r=0===t&&e===r.length-1?r:r.slice(t,e+1),this._valueRange=Math.max(r.length-(n?0:1),1),this._startValue=this.min-(n?.5:0);for(let o=t;o<=e;o++)i.push({value:o});return i}getLabelForValue(t){return xi.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return\"number\"!==typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.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(T<r&&!x&&!y)return[{value:m},{value:b}];S=Math.ceil(b/T)-Math.floor(m/T),S>g&&(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}),_<a&&C++,(0,i.aK)(Math.round((_+C*T)*k)/k,a,wi(a,w,t))&&C++):_<a&&C++);C<S;++C){const t=Math.round((_+C*T)*k)/k;if(y&&t>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<r-1;++o){const t=Mi[Si[o]],r=t.steps?t.steps:Number.MAX_SAFE_INTEGER;if(t.common&&Math.ceil((n-e)/(r*t.size))<=i)return Si[o]}return Si[r-1]}function Ai(t,e,n,i,r){for(let o=Si.length-1;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<n;++e)if(Mi[Si[e]].common)return Si[e]}function Pi(t,e,n){if(n){if(n.length){const{lo:r,hi:o}=(0,i.aQ)(n,e),s=n[r]>=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<o;++s)a=e[s],r[a]=s,i.push({value:a,major:!1});return 0!==o&&n?Ei(t,i,r,n):i}class Ii extends Kt{static id=\"time\";static defaults={bounds:\"data\",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:\"millisecond\",displayFormats:{}},ticks:{source:\"auto\",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit=\"day\",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const n=t.time||(t.time={}),r=this._adapter=new N._date(t.adapters.date);r.init(e),(0,i.ab)(n.displayFormats,r.formats()),this._parseOpts={parser:n.parser,round:n.round,isoWeekday:n.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Di(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,n=t.time.unit||\"day\";let{min:r,max:o,minDefined:s,maxDefined:a}=this.getUserBounds();function l(t){s||isNaN(t.min)||(r=Math.min(r,t.min)),a||isNaN(t.max)||(o=Math.max(o,t.max))}s&&a||(l(this._getLabelBounds()),\"ticks\"===t.bounds&&\"labels\"===t.ticks.source||l(this.getMinMax(!1))),r=(0,i.g)(r)&&!isNaN(r)?r:+e.startOf(Date.now(),n),o=(0,i.g)(o)&&!isNaN(o)?o:+e.endOf(Date.now(),n)+1,this.min=Math.min(r,o-1),this.max=Math.max(r+1,o)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,n=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],n=t[t.length-1]),{min:e,max:n}}buildTicks(){const t=this.options,e=t.time,n=t.ticks,r=\"labels\"===n.source?this.getLabelTimestamps():this._generate();\"ticks\"===t.bounds&&r.length&&(this.min=this._userMin||r[0],this.max=this._userMax||r[r.length-1]);const o=this.min,s=this.max,a=(0,i.aP)(r,o,s);return this._unit=e.unit||(n.autoSkip?Ci(e.minUnit,this.min,this.max,this._getLabelCapacity(o)):Ai(this,a.length,e.minUnit,this.min,this.max)),this._majorUnit=n.major.enabled&&\"year\"!==this._unit?Oi(this._unit):void 0,this.initOffsets(r),t.reverse&&a.reverse(),Ri(this,a,this._majorUnit)}afterAutoSkip(){this.options.offsetAfterAutoskip&&this.initOffsets(this.ticks.map((t=>+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<n;h=+t.add(h,a,s),d++)Pi(u,h,p);return h!==n&&\"ticks\"!==r.bounds&&1!==d||Pi(u,h,p),Object.keys(u).sort(Ti).map((t=>+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;e<n;++e)i=t[e],i.label=this._tickFormatFunction(i.value,e,t)}getDecimalForValue(t){return null===t?NaN:(t-this.min)/(this.max-this.min)}getPixelForValue(t){const e=this._offsets,n=this.getDecimalForValue(t);return this.getPixelForDecimal((e.start+n)*e.factor)}getValueForPixel(t){const e=this._offsets,n=this.getDecimalForPixel(t)/e.factor-e.end;return this.min+n*(this.max-this.min)}_getLabelSize(t){const e=this.options.ticks,n=this.ctx.measureText(t).width,r=(0,i.t)(this.isHorizontal()?e.maxRotation:e.minRotation),o=Math.cos(r),s=Math.sin(r),a=this._resolveTickFontOptions(0).size;return{w:n*o+a*s,h:n*s+a*o}}_getLabelCapacity(t){const e=this.options.time,n=e.displayFormats,i=n[e.unit]||n.millisecond,r=this._tickFormatFunction(t,0,Ri(this,[t],this._majorUnit),i),o=this._getLabelSize(r),s=Math.floor(this.isHorizontal()?this.width/o.w:this.height/o.h)-1;return s>0?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;t<e;++t)n=n.concat(i[t].controller.getAllParsedValues(this));return this._cache.data=this.normalize(n)}getLabelTimestamps(){const t=this._cache.labels||[];let e,n;if(t.length)return t;const i=this.getLabels();for(e=0,n=i.length;e<n;++e)t.push(Di(this,i[e]));return this._cache.labels=this._normalized?t:this.normalize(t)}normalize(t){return(0,i._)(t.sort(Ti))}}Ii.defaults},411:function(t,e,n){\n/*!\n * @kurkle/color v0.3.4\n * https://github.com/kurkle/color#readme\n * (c) 2024 Jukka Kurkela\n * Released under the MIT License\n */\nfunction i(t){return t+.5|0}n.d(e,{$:function(){return Ve},A:function(){return Jt},B:function(){return Qt},C:function(){return Be},D:function(){return $t},E:function(){return an},F:function(){return ot},G:function(){return ti},H:function(){return Dt},I:function(){return Bn},J:function(){return ii},K:function(){return ni},L:function(){return oe},M:function(){return $n},N:function(){return It},O:function(){return tt},P:function(){return kt},Q:function(){return rt},R:function(){return un},S:function(){return qt},T:function(){return _t},U:function(){return Ht},V:function(){return Ne},W:function(){return Xt},X:function(){return je},Y:function(){return Ye},Z:function(){return Qe},_:function(){return ie},a:function(){return cn},a0:function(){return ln},a1:function(){return ae},a2:function(){return le},a3:function(){return Oe},a4:function(){return ut},a5:function(){return bt},a6:function(){return Pe},a7:function(){return yt},a8:function(){return fn},a9:function(){return dn},aA:function(){return ci},aB:function(){return ui},aC:function(){return ce},aD:function(){return hi},aE:function(){return $e},aF:function(){return Bt},aG:function(){return X},aH:function(){return Ft},aI:function(){return Rt},aJ:function(){return Nt},aK:function(){return Et},aL:function(){return Wt},aM:function(){return Ce},aN:function(){return Ot},aO:function(){return Fe},aP:function(){return Kt},aQ:function(){return Zt},aa:function(){return pn},ab:function(){return ht},ac:function(){return G},ad:function(){return se},ae:function(){return ei},af:function(){return He},ag:function(){return vt},ah:function(){return Ti},ai:function(){return st},aj:function(){return wt},ak:function(){return Gt},al:function(){return Vt},am:function(){return rn},an:function(){return Wn},ao:function(){return yi},ap:function(){return mi},aq:function(){return oi},ar:function(){return si},as:function(){return ri},at:function(){return Ue},au:function(){return qe},av:function(){return We},aw:function(){return Je},ax:function(){return on},ay:function(){return sn},az:function(){return gi},b:function(){return Q},b4:function(){return Tt},b5:function(){return Ct},b6:function(){return At},c:function(){return be},d:function(){return Le},e:function(){return ge},f:function(){return mt},g:function(){return K},h:function(){return xt},i:function(){return J},j:function(){return hn},k:function(){return Z},l:function(){return ee},m:function(){return nt},n:function(){return it},o:function(){return Se},p:function(){return Ut},q:function(){return ue},r:function(){return re},s:function(){return Pt},t:function(){return jt},u:function(){return ne},v:function(){return et},w:function(){return he},x:function(){return zt},y:function(){return Pn},z:function(){return Qn}});const r=(t,e,n)=>Math.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<n?6:0):e===r?(n-t)/i+2:(t-e)/i+4}function _(t){const e=255,n=t.r/e,i=t.g/e,r=t.b/e,o=Math.max(n,i,r),s=Math.min(n,i,r),a=(o+s)/2;let l,c,u;return o!==s&&(u=o-s,c=a>.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<e.length;i++){for(s=a=e[i],r=0;r<n.length;r++)o=n[r],a=a.replace(o,E[o]);o=parseInt(R[s],16),t[a]=[o>>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}}\n/*!\n * Chart.js v4.5.1\n * https://www.chartjs.org\n * (c) 2025 Chart.js Contributors\n * Released under the MIT License\n */\nfunction 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;r<o;r++)e.call(n,t[r],r);else if(J(t))for(s=Object.keys(t),o=s.length,r=0;r<o;r++)e.call(n,t[s[r]],s[r])}function st(t,e){let n,i,r,o;if(!t||!e||t.length!==e.length)return!1;for(n=0,i=t.length;n<i;++n)if(r=t[n],o=e[n],r.datasetIndex!==o.datasetIndex||r.index!==o.index)return!1;return!0}function at(t){if(Q(t))return t.map(at);if(J(t)){const e=Object.create(null),n=Object.keys(t),i=n.length;let r=0;for(;r<i;++r)e[n[r]]=at(t[n[r]]);return e}return t}function lt(t){return-1===[\"__proto__\",\"prototype\",\"constructor\"].indexOf(t)}function ct(t,e,n,i){if(!lt(t))return;const r=e[t],o=n[t];J(r)&&J(o)?ut(r,o,i):e[t]=at(o)}function ut(t,e,n){const i=Q(e)?e:[e],r=i.length;if(!J(t))return t;n=n||{};const o=n.merger||ct;let s;for(let a=0;a<r;++a){if(s=i[a],!J(s))continue;const e=Object.keys(s);for(let i=0,r=e.length;i<r;++i)o(e[i],t,s,n)}return t}function ht(t,e){return ut(t,e,{merger:dt})}function dt(t,e,n){if(!lt(t))return;const i=e[t],r=n[t];J(i)&&J(r)?ht(i,r):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=at(r))}const ft={\"\":t=>t,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)<n}function Rt(t){const e=Math.round(t);t=Et(t,e,t/1e3)?e:t;const n=Math.pow(10,Math.floor(Ot(t))),i=t/n,r=i<=1?1:i<=2?2:i<=5?5:10;return r*n}function It(t){const e=[],n=Math.sqrt(t);let i;for(i=1;i<n;i++)t%i===0&&(e.push(i),e.push(t/i));return n===(0|n)&&e.push(n),e.sort(((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;i<r;i++)o=t[i][n],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function jt(t){return t*(kt/180)}function Ht(t){return t*(180/kt)}function Wt(t){if(!K(t))return;let e=1,n=0;while(Math.round(t*e)/e!==t)e*=10,n++;return n}function $t(t,e){const n=e.x-t.x,i=e.y-t.y,r=Math.sqrt(n*n+i*i);let o=Math.atan2(i,n);return o<-.5*kt&&(o+=_t),{angle:o,distance:r}}function Bt(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function Yt(t,e){return(t-e+Mt)%_t-kt}function Vt(t){return(t%_t+_t)%_t}function Ut(t,e,n,i){const r=Vt(t),o=Vt(e),s=Vt(n),a=Vt(o-r),l=Vt(s-r),c=Vt(r-o),u=Vt(r-s);return r===o||r===s||i&&o===s||a>l&&c<u}function qt(t,e,n){return Math.max(e,Math.min(n,t))}function Xt(t){return qt(t,-32768,32767)}function Gt(t,e,n,i=1e-6){return t>=Math.min(e,n)-i&&t<=Math.max(e,n)+i}function Zt(t,e,n){n=n||(n=>t[n]<e);let i,r=t.length-1,o=0;while(r-o>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 r<n||r===n&&t[i+1][e]===n}:i=>t[i][e]<n),Jt=(t,e,n)=>Zt(t,n,(i=>t[i][e]>=n));function Kt(t,e,n){let i=0,r=t.length;while(i<r&&t[i]<e)i++;while(r>i&&t[r-1]>n)r--;return i>0||r<t.length?t.slice(i,r):t}const te=[\"push\",\"pop\",\"shift\",\"splice\",\"unshift\"];function ee(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,\"_chartjs\",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),te.forEach((e=>{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;i<r;++i){const e=n[i];t=t[e]||(t[e]=Object.create(null))}return t}function Re(t,e,n){return\"string\"===typeof e?ut(Ee(t,e),n):ut(Ee(t,\"\"),e)}class Ie{constructor(t,e){this.animation=void 0,this.backgroundColor=\"rgba(0,0,0,0.1)\",this.borderColor=\"rgba(0,0,0,0.1)\",this.color=\"#666\",this.datasets={},this.devicePixelRatio=t=>t.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;l<a;l++)if(h=n[l],void 0===h||null===h||Q(h)){if(Q(h))for(c=0,u=h.length;c<u;c++)d=h[c],void 0===d||null===d||Q(d)||(s=Ne(t,r,o,s,d))}else s=Ne(t,r,o,s,h);t.restore();const f=o.length/2;if(f>n.length){for(l=0;l<f;l++)delete r[o[l]];o.splice(0,f)}return s}function je(t,e,n){const i=t.currentDevicePixelRatio,r=0!==n?Math.max(n/2,.5):0;return Math.round((e-r)*i)/i+r}function He(t,e){(e||t)&&(e=e||t.getContext(\"2d\"),e.save(),e.resetTransform(),e.clearRect(0,0,t.width,t.height),e.restore())}function We(t,e,n,i){$e(t,e,n,i,null)}function $e(t,e,n,i,r){let o,s,a,l,c,u,h,d;const f=e.pointStyle,p=e.rotation,g=e.radius;let m=(p||0)*Tt;if(f&&\"object\"===typeof f&&(o=f.toString(),\"[object HTMLImageElement]\"===o||\"[object HTMLCanvasElement]\"===o))return t.save(),t.translate(n,i),t.rotate(m),t.drawImage(f,-f.width/2,-f.height/2,f.width,f.height),void t.restore();if(!(isNaN(g)||g<=0)){switch(t.beginPath(),f){default:r?t.ellipse(n,i,r/2,g,0,0,_t):t.arc(n,i,g,0,_t),t.closePath();break;case\"triangle\":u=r?r/2:g,t.moveTo(n+Math.sin(m)*u,i-Math.cos(m)*g),m+=At,t.lineTo(n+Math.sin(m)*u,i-Math.cos(m)*g),m+=At,t.lineTo(n+Math.sin(m)*u,i-Math.cos(m)*g),t.closePath();break;case\"rectRounded\":c=.516*g,l=g-c,s=Math.cos(m+Ct)*l,h=Math.cos(m+Ct)*(r?r/2-c:l),a=Math.sin(m+Ct)*l,d=Math.sin(m+Ct)*(r?r/2-c:l),t.arc(n-h,i-a,c,m-kt,m-Dt),t.arc(n+d,i-s,c,m-Dt,m),t.arc(n+h,i+a,c,m,m+Dt),t.arc(n-d,i+s,c,m+Dt,m+kt),t.closePath();break;case\"rect\":if(!p){l=Math.SQRT1_2*g,u=r?r/2:l,t.rect(n-u,i-l,2*u,2*l);break}m+=Ct;case\"rectRot\":h=Math.cos(m)*(r?r/2:g),s=Math.cos(m)*g,a=Math.sin(m)*g,d=Math.sin(m)*(r?r/2:g),t.moveTo(n-h,i-a),t.lineTo(n+d,i-s),t.lineTo(n+h,i+a),t.lineTo(n-d,i+s),t.closePath();break;case\"crossRot\":m+=Ct;case\"cross\":h=Math.cos(m)*(r?r/2:g),s=Math.cos(m)*g,a=Math.sin(m)*g,d=Math.sin(m)*(r?r/2:g),t.moveTo(n-h,i-a),t.lineTo(n+h,i+a),t.moveTo(n+d,i-s),t.lineTo(n-d,i+s);break;case\"star\":h=Math.cos(m)*(r?r/2:g),s=Math.cos(m)*g,a=Math.sin(m)*g,d=Math.sin(m)*(r?r/2:g),t.moveTo(n-h,i-a),t.lineTo(n+h,i+a),t.moveTo(n+d,i-s),t.lineTo(n-d,i+s),m+=Ct,h=Math.cos(m)*(r?r/2:g),s=Math.cos(m)*g,a=Math.sin(m)*g,d=Math.sin(m)*(r?r/2:g),t.moveTo(n-h,i-a),t.lineTo(n+h,i+a),t.moveTo(n+d,i-s),t.lineTo(n-d,i+s);break;case\"line\":s=r?r/2:Math.cos(m)*g,a=Math.sin(m)*g,t.moveTo(n-s,i-a),t.lineTo(n+s,i+a);break;case\"dash\":t.moveTo(n,i),t.lineTo(n+Math.cos(m)*(r?r/2:g),i+Math.sin(m)*g);break;case!1:t.closePath();break}t.fill(),e.borderWidth>0&&t.stroke()}}function Be(t,e,n){return n=n||.5,!e||t&&t.x>e.left-n&&t.x<e.right+n&&t.y>e.top-n&&t.y<e.bottom+n}function Ye(t,e){t.save(),t.beginPath(),t.rect(e.left,e.top,e.right-e.left,e.bottom-e.top),t.clip()}function Ve(t){t.restore()}function Ue(t,e,n,i,r){if(!e)return t.lineTo(n.x,n.y);if(\"middle\"===r){const i=(e.x+n.x)/2;t.lineTo(i,e.y),t.lineTo(i,n.y)}else\"after\"===r!==!!i?t.lineTo(e.x,n.y):t.lineTo(n.x,e.y);t.lineTo(n.x,n.y)}function qe(t,e,n,i){if(!e)return t.lineTo(n.x,n.y);t.bezierCurveTo(i?e.cp1x:e.cp2x,i?e.cp1y:e.cp2y,i?n.cp2x:n.cp1x,i?n.cp2y:n.cp1y,n.x,n.y)}function Xe(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),Z(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}function Ge(t,e,n,i,r){if(r.strikethrough||r.underline){const o=t.measureText(i),s=e-o.actualBoundingBoxLeft,a=e+o.actualBoundingBoxRight,l=n-o.actualBoundingBoxAscent,c=n+o.actualBoundingBoxDescent,u=r.strikethrough?(l+c)/2:c;t.strokeStyle=t.fillStyle,t.beginPath(),t.lineWidth=r.decorationWidth||2,t.moveTo(s,u),t.lineTo(a,u),t.stroke()}}function Ze(t,e){const n=t.fillStyle;t.fillStyle=e.color,t.fillRect(e.left,e.top,e.width,e.height),t.fillStyle=n}function Qe(t,e,n,i,r,o={}){const s=Q(e)?e:[e],a=o.strokeWidth>0&&\"\"!==o.strokeColor;let l,c;for(t.save(),t.font=r.string,Xe(t,o),l=0;l<s.length;++l)c=s[l],o.backdrop&&Ze(t,o.backdrop),a&&(o.strokeColor&&(t.strokeStyle=o.strokeColor),Z(o.strokeWidth)||(t.lineWidth=o.strokeWidth),t.strokeText(c,n,i,o.maxWidth)),t.fillText(c,n,i,o.maxWidth),Ge(t,n,i,c,o),i+=Number(r.lineHeight);t.restore()}function Je(t,e){const{x:n,y:i,w:r,h:o,radius:s}=e;t.arc(n+s.topLeft,i+s.topLeft,s.topLeft,1.5*kt,kt,!0),t.lineTo(n,i+o-s.bottomLeft),t.arc(n+s.bottomLeft,i+o-s.bottomLeft,s.bottomLeft,kt,Dt,!0),t.lineTo(n+r-s.bottomRight,i+o),t.arc(n+r-s.bottomRight,i+o-s.bottomRight,s.bottomRight,Dt,0,!0),t.lineTo(n+r,i+s.topRight),t.arc(n+r-s.topRight,i+s.topRight,s.topRight,0,-Dt,!0),t.lineTo(n+s.topLeft,i)}const Ke=/^(normal|(\\d+(?:\\.\\d+)?)(px|em|%)?)$/,tn=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function en(t,e){const n=(\"\"+t).match(Ke);if(!n||\"normal\"===n[1])return 1.2*e;switch(t=+n[2],n[3]){case\"px\":return t;case\"%\":t/=100;break}return e*t}const nn=t=>+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;r<o;++r)if(s=t[r],void 0!==s&&(void 0!==e&&\"function\"===typeof s&&(s=s(e),a=!1),void 0!==n&&Q(s)&&(s=s[n%s.length],a=!1),void 0!==s))return i&&!a&&(i.cacheable=!1),s}function un(t,e,n){const{min:i,max:r}=t,o=it(e,(r-i)/2),s=(t,e)=>n&&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;a<l;++a)c=a+n,u=e[c],s[a]={r:r.parse(mt(u,o),c)};return s}const En=Number.EPSILON||1e-14,Rn=(t,e)=>e<t.length&&!t[e].skip&&t[e],In=t=>\"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<i-1;++u)l=c,c=Rn(t,u+1),l&&c&&(Et(e[u],0,En)?n[u]=n[u+1]=0:(r=n[u]/e[u],o=n[u+1]/e[u],a=Math.pow(r,2)+Math.pow(o,2),a<=9||(s=3/Math.sqrt(a),n[u]=r*s*e[u],n[u+1]=o*s*e[u])))}function Nn(t,e,n=\"x\"){const i=In(n),r=t.length;let o,s,a,l=Rn(t,0);for(let c=0;c<r;++c){if(s=a,a=l,l=Rn(t,c+1),!a)continue;const r=a[n],u=a[i];s&&(o=(r-s[n])/3,a[`cp1${n}`]=r-o,a[`cp1${i}`]=u-o*e[c]),l&&(o=(l[n]-r)/3,a[`cp2${n}`]=r+o,a[`cp2${i}`]=u+o*e[c])}}function Fn(t,e=\"x\"){const n=In(e),i=t.length,r=Array(i).fill(0),o=Array(i);let s,a,l,c=Rn(t,0);for(s=0;s<i;++s)if(a=l,l=c,c=Rn(t,s+1),l){if(c){const t=c[e]-l[e];r[s]=0!==t?(c[n]-l[n])/t:0}o[s]=a?c?Pt(r[s-1])!==Pt(r[s])?0:(r[s-1]+r[s])/2:r[s-1]:r[s]}zn(t,r,o),Nn(t,o,e)}function jn(t,e,n){return Math.max(Math.min(t,n),e)}function Hn(t,e){let n,i,r,o,s,a=Be(t[0],e);for(n=0,i=t.length;n<i;++n)s=o,o=a,a=n<i-1&&Be(t[n+1],e),o&&(r=t[n],s&&(r.cp1x=jn(r.cp1x,e.left,e.right),r.cp1y=jn(r.cp1y,e.top,e.bottom)),a&&(r.cp2x=jn(r.cp2x,e.left,e.right),r.cp2y=jn(r.cp2y,e.top,e.bottom)))}function Wn(t,e,n,i,r){let o,s,a,l;if(e.spanGaps&&(t=t.filter((t=>!t.skip))),\"monotone\"===e.cubicInterpolationMode)Fn(t,r);else{let n=i?t[t.length-1]:t[0];for(o=0,s=t.length;o<s;++o)a=t[o],l=Ln(n,a,t[Math.min(o+1,s-(i?0:1))%s],e.tension),a.cp1x=l.previous.x,a.cp1y=l.previous.y,a.cp2x=l.next.x,a.cp2y=l.next.y,n=a}e.capBezierPoints&&Hn(t,n)}function $n(){return\"undefined\"!==typeof window&&\"undefined\"!==typeof document}function Bn(t){let e=t.parentNode;return e&&\"[object ShadowRoot]\"===e.toString()&&(e=e.host),e}function Yn(t,e,n){let i;return\"string\"===typeof t?(i=parseInt(t,10),-1!==t.indexOf(\"%\")&&(i=i/100*e.parentNode[n])):i=t,i}const Vn=t=>t.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;c<u;++c){if(!s(a(e[h%l][i]),r,o))break;h--,d--}h%=l,d%=l}return d<h&&(d+=l),{start:h,end:d,loop:f,style:t.style}}function gi(t,e,n){if(!n)return[t];const{property:i,start:r,end:o}=n,s=e.length,{compare:a,between:l,normalize:c}=di(i),{start:u,end:h,loop:d,style:f}=pi(t,e,n),p=[];let g,m,b,x=!1,y=null;const v=()=>l(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;r<i.length;r++){const o=gi(i[r],t.points,e);o.length&&n.push(...o)}return n}function bi(t,e,n,i){let r=0,o=e-1;if(n&&!i)while(r<e&&!t[r].skip)r++;while(r<e&&t[r].skip)r++;r%=e,n&&(o+=r);while(o>r&&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<s?a+r:a,c=!!t._fullLoop&&0===s&&a===r-1;return vi(t,xi(n,s,l,c),n,e)}function vi(t,e,n,i){return i&&i.setContext&&n?wi(t,e,n,i):e}function wi(t,e,n,i){const r=t._chart.getContext(),o=ki(t.options),{_datasetIndex:s,options:{spanGaps:a}}=t,l=n.length,c=[];let u=o,h=e[0].start,d=h;function f(t,e,i,r){const o=a?-1:1;if(t!==e){t+=l;while(n[t%l].skip)t-=o;while(n[e%l].skip)e+=o;t%l!==e%l&&(c.push({start:t%l,end:e%l,loop:i,style:r}),u=r,h=e%l)}}for(const p of e){h=a?h:p.start;let t,e=n[h%l];for(d=h+1;d<=p.end;d++){const o=n[d%l];t=ki(i.setContext(hn(r,{type:\"segment\",p0:e,p1:o,p0DataIndex:(d-1)%l,p1DataIndex:d%l,datasetIndex:s}))),_i(t,u)&&f(h,d-1,p.loop,u),e=o,u=t}h<d-1&&f(h,d-1,p.loop,u)}return c}function ki(t){return{backgroundColor:t.backgroundColor,borderCapStyle:t.borderCapStyle,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderJoinStyle:t.borderJoinStyle,borderWidth:t.borderWidth,borderColor:t.borderColor}}function _i(t,e){if(!e)return!1;const n=[],i=function(t,e){return me(e)?(n.includes(e)||n.push(e),n.indexOf(e)):e};return JSON.stringify(t,i)!==JSON.stringify(e,i)}function Mi(t,e,n){return t.options.clip?t[n]:e[n]}function Si(t,e){const{xScale:n,yScale:i}=t;return n&&i?{left:Mi(n,e,\"left\"),right:Mi(n,e,\"right\"),top:Mi(i,e,\"top\"),bottom:Mi(i,e,\"bottom\")}:e}function Ti(t,e){const n=e._clip;if(n.disabled)return!1;const i=Si(e,t.chartArea);return{left:!1===n.left?0:i.left-(!0===n.left?0:n.left),right:!1===n.right?t.width:i.right+(!0===n.right?0:n.right),top:!1===n.top?0:i.top-(!0===n.top?0:n.top),bottom:!1===n.bottom?t.height:i.bottom+(!0===n.bottom?0:n.bottom)}}},210:function(t,e,n){var i=n(148);Math.pow(10,8);const r=6048e5,o=864e5,s=6e4,a=36e5,l=1e3,c=Symbol.for(\"constructDateFrom\");function u(t,e){return\"function\"===typeof t?t(e):t&&\"object\"===typeof t&&c in t?t[c](e):t instanceof Date?new t.constructor(e):new Date(e)}function h(t,e){return u(e||t,t)}const d={lessThanXSeconds:{one:\"less than a second\",other:\"less than {{count}} seconds\"},xSeconds:{one:\"1 second\",other:\"{{count}} seconds\"},halfAMinute:\"half a minute\",lessThanXMinutes:{one:\"less than a minute\",other:\"less than {{count}} minutes\"},xMinutes:{one:\"1 minute\",other:\"{{count}} minutes\"},aboutXHours:{one:\"about 1 hour\",other:\"about {{count}} hours\"},xHours:{one:\"1 hour\",other:\"{{count}} hours\"},xDays:{one:\"1 day\",other:\"{{count}} days\"},aboutXWeeks:{one:\"about 1 week\",other:\"about {{count}} weeks\"},xWeeks:{one:\"1 week\",other:\"{{count}} weeks\"},aboutXMonths:{one:\"about 1 month\",other:\"about {{count}} months\"},xMonths:{one:\"1 month\",other:\"{{count}} months\"},aboutXYears:{one:\"about 1 year\",other:\"about {{count}} years\"},xYears:{one:\"1 year\",other:\"{{count}} years\"},overXYears:{one:\"over 1 year\",other:\"over {{count}} years\"},almostXYears:{one:\"almost 1 year\",other:\"almost {{count}} years\"}},f=(t,e,n)=>{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<t.length;n++)if(e(t[n]))return n}function R(t){return(e,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<i?7:0)+o-i;return r.setDate(r.getDate()-s),r.setHours(0,0,0,0),r}function At(t,e){const n=h(t,e?.in),i=n.getFullYear(),r=st(),o=e?.firstWeekContainsDate??e?.locale?.options?.firstWeekContainsDate??r.firstWeekContainsDate??r.locale?.options?.firstWeekContainsDate??1,s=u(e?.in||t,0);s.setFullYear(i+1,0,o),s.setHours(0,0,0,0);const a=Ct(s,e),l=u(e?.in||t,0);l.setFullYear(i,0,o),l.setHours(0,0,0,0);const c=Ct(l,e);return+n>=+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+(o<i?-7:0)-(o-i);return r.setDate(r.getDate()+s),r.setHours(23,59,59,999),r}function Qn(t,e){const n=h(t,e?.in),i=n.getMonth(),r=i-i%3+3;return n.setMonth(r,0),n.setHours(23,59,59,999),n}function Jn(t,e){const n=h(t,e?.in),i=n.getFullYear();return n.setFullYear(i+1,0,0),n.setHours(23,59,59,999),n}\n/*!\n * chartjs-adapter-date-fns v3.0.0\n * https://www.chartjs.org\n * (c) 2022 chartjs-adapter-date-fns Contributors\n * Released under the MIT license\n */\nconst Kn={datetime:\"MMM d, yyyy, h:mm:ss aaaa\",millisecond:\"h:mm:ss.SSS aaaa\",second:\"h:mm:ss aaaa\",minute:\"h:mm aaaa\",hour:\"ha\",day:\"MMM d\",week:\"PP\",month:\"MMM yyyy\",quarter:\"qqq - yyyy\",year:\"yyyy\"};i.IQ._date.override({_id:\"date-fns\",formats:function(){return Kn},parse:function(t,e){if(null===t||\"undefined\"===typeof t)return null;const n=typeof t;return\"number\"===n||t instanceof Date?t=h(t):\"string\"===n&&(t=\"string\"===typeof e?De(t,e,new Date,this.options):Ae(t,this.options)),Ze(t)?t.getTime():null},format:function(t,e){return gn(t,e,this.options)},add:function(t,e,n){switch(n){case\"millisecond\":return bn(t,e);case\"second\":return xn(t,e);case\"minute\":return yn(t,e);case\"hour\":return vn(t,e);case\"day\":return Qt(t,e);case\"week\":return wn(t,e);case\"month\":return kn(t,e);case\"quarter\":return _n(t,e);case\"year\":return Mn(t,e);default:return t}},diff:function(t,e,n){switch(n){case\"millisecond\":return Sn(t,e);case\"second\":return Dn(t,e);case\"minute\":return Cn(t,e);case\"hour\":return An(t,e);case\"day\":return On(t,e);case\"week\":return En(t,e);case\"month\":return Fn(t,e);case\"quarter\":return jn(t,e);case\"year\":return Wn(t,e);default:return 0}},startOf:function(t,e,n){switch(e){case\"second\":return $n(t);case\"minute\":return Bn(t);case\"hour\":return Yn(t);case\"day\":return Je(t);case\"week\":return Ct(t);case\"isoWeek\":return Ct(t,{weekStartsOn:+n});case\"month\":return Vn(t);case\"quarter\":return Un(t);case\"year\":return tn(t);default:return t}},endOf:function(t,e){switch(e){case\"second\":return qn(t);case\"minute\":return Xn(t);case\"hour\":return Gn(t);case\"day\":return Ln(t);case\"week\":return Zn(t);case\"month\":return zn(t);case\"quarter\":return Qn(t);case\"year\":return Jn(t);default:return t}}})},282:function(t,e,n){n.d(e,{Z:function(){return an}});var i=n(148),r=n(411);\n/*!\n* chartjs-plugin-annotation v3.1.0\n* https://www.chartjs.org/chartjs-plugin-annotation/index\n * (c) 2024 chartjs-plugin-annotation Contributors\n * Released under the MIT License\n */\nconst o={modes:{point(t,e){return c(t,e,{intersect:!0})},nearest(t,e,n){return u(t,e,n)},x(t,e,n){return c(t,e,{intersect:n.intersect,axis:\"x\"})},y(t,e,n){return c(t,e,{intersect:n.intersect,axis:\"y\"})}}};function s(t,e,n){const i=o.modes[n.mode]||o.modes.nearest;return i(t,e,n)}function a(t,e,n){return\"x\"!==n&&\"y\"!==n?t.inRange(e.x,e.y,\"x\",!0)||t.inRange(e.x,e.y,\"y\",!0):t.inRange(e.x,e.y,n,!0)}function l(t,e,n){return\"x\"===n?{x:t.x,y:e.y}:\"y\"===n?{x:e.x,y:t.y}:e}function c(t,e,n){return t.filter((t=>n.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 c<i?(t=[o],i=c):c===i&&t.push(o),t}),[]).sort(((t,e)=>t._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)<parseInt(a,10))break;if(d(a,s)){if(i)throw new Error(`${t} v${n} is not supported. v${e} or newer is required.`);return!1}}return!0}const k=t=>\"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;a<r;a++){const r=n[Math.min(a,n.length-1)];t.font=r.string;const l=e[a];o=Math.max(o,t.measureText(l).width+i),s+=r.lineHeight}return t.restore(),{width:o,height:s}}function tt(t,{x:e,y:n},i,r){t.beginPath();let o=0;i.forEach((function(i,s){const a=r[Math.min(s,r.length-1)],l=a.lineHeight;t.font=a.string,t.strokeText(i,e,n+l/2+o),o+=l})),t.stroke()}function et(t,{x:e,y:n},i,{fonts:r,colors:o}){let s=0;i.forEach((function(i,a){const l=o[Math.min(a,o.length-1)],c=r[Math.min(a,r.length-1)],u=c.lineHeight;t.beginPath(),t.font=c.string,t.fillStyle=l,t.fillText(i,e,n+u/2+s),s+=u,t.fill()}))}function nt(t,e){const n=(0,r.x)(t)?t:e;return(0,r.x)(n)?p(n,0,1):1}const it=[\"left\",\"bottom\",\"top\",\"right\"];function rt(t,e){const{pointX:n,pointY:i,options:o}=e,s=o.callout,a=s&&s.display&&ct(e,s);if(!a||ht(e,s,a))return;t.save(),t.beginPath();const l=V(t,s);if(!l)return t.restore();const{separatorStart:c,separatorEnd:u}=ot(e,a),{sideStart:d,sideEnd:f}=at(e,a,c);(s.margin>0||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<n.controller.innerRadius)&&o.options.circumference>=90?r:n}),void 0)}function Qt(t,e,n){if(!e.autoHide)return!0;for(let i=0;i<n.length;i++)if(!n[i].hidden&&t.getDataVisibility(i))return!0}function Jt({chartArea:t},e,n){const{left:i,top:r,right:o,bottom:s}=t,{innerRadius:a,offsetX:l,offsetY:c}=n.controller,u=(i+o)/2+l,h=(r+s)/2+c,d={left:Math.max(u-a,i),right:Math.min(u+a,o),top:Math.max(h-a,r),bottom:Math.min(h+a,s)},f={x:(d.left+d.right)/2,y:(d.top+d.bottom)/2},p=e.spacing+e.borderWidth/2,g=a-p,m=f.y>h,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!(t<a&&n<a||t>o&&n>o||e<r&&i<r||e>s&&i>s)}function be({x:t,y:e},n,{top:i,right:r,bottom:o,left:s}){return t<s&&(e=se(s,{x:t,y:e},n),t=s),t>r&&(e=se(r,{x:t,y:e},n),t=r),e<i&&(t=oe(i,{x:t,y:e},n),e=i),e>o&&(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:s<r.P/-2?s+r.P:s}function _e(t,e,n,i){const{width:o,height:s,padding:a}=n,{xAdjust:l,yAdjust:c}=e,u={x:t.x,y:t.y},h={x:t.x2,y:t.y2},d=\"auto\"===e.rotation?ke(t):(0,r.t)(e.rotation),f=Me(o,s,d),p=Se(t,e,{labelSize:f,padding:a},i),g=t.cp?ue(u,t.cp,h,p):re(u,h,p),m={size:f.w,min:i.left,max:i.right,padding:a.left},b={size:f.h,min:i.top,max:i.bottom,padding:a.top},x=Ce(g.x,m)+l,y=Ce(g.y,b)+c;return{x:x-o/2,y:y-s/2,x2:x+o/2,y2:y+s/2,centerX:x,centerY:y,pointX:g.x,pointY:g.y,width:o,height:s,rotation:(0,r.U)(d)}}function Me(t,e,n){const i=Math.cos(n),r=Math.sin(n);return{w:Math.abs(t*i)+Math.abs(e*r),h:Math.abs(t*r)+Math.abs(e*i)}}function Se(t,e,n,i){let r;const o=De(t,i);return r=\"start\"===e.position?Te({w:t.x2-t.x,h:t.y2-t.y},n,e,o):\"end\"===e.position?1-Te({w:t.x-t.x2,h:t.y-t.y2},n,e,o):D(1,e.position),r}function Te(t,e,n,i){const{labelSize:r,padding:o}=e,s=t.w*i.dx,a=t.h*i.dy,l=s>0&&(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;r<i;r++,l+=a){const i=je(n,e,l);i.initProperties=z(t,n,e),s.push(i)}return n.elements=s,n}}function je({centerX:t,centerY:e},{radius:n,borderWidth:i,hitTolerance:r},o){const s=(i+r)/2,a=Math.sin(o),l=Math.cos(o),c={x:t+a*n,y:e-l*n};return{type:\"point\",optionScope:\"point\",properties:{x:c.x,y:c.y,centerX:c.x,centerY:c.y,bX:t+a*(n+s),bY:e-l*(n+s)}}}function He(t,e,n,i){let r=!1,o=t[t.length-1].getProps([\"bX\",\"bY\"],i);for(const s of t){const t=s.getProps([\"bX\",\"bY\"],i);t.bY>n!==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;l<s.length;l++){const e=s[l],n=Qe(a,l,e.type),i=e.setContext(tn(t,n,a,e)),c=n.resolveElementProperties(t,i);c.skip=Xe(c),\"elements\"in c&&(Ze(n,c.elements,i,o),delete c.elements),(0,r.h)(n.x)||Object.assign(n,c),Object.assign(n,c.initProperties),c.options=Je(i),o.update(n,c)}}function Xe(t){return isNaN(t.x)||isNaN(t.y)}function Ge(t,e,n){return\"reset\"===n||\"none\"===n||\"resize\"===n?$e:new i.FK(t,e)}function Ze(t,e,n,i){const r=t.elements||(t.elements=[]);r.length=e.length;for(let o=0;o<e.length;o++){const t=e[o],s=t.properties,a=Qe(r,o,t.type,t.initProperties),l=n[t.optionScope].override(t);s.options=Je(l),i.update(a,s)}}function Qe(t,e,n,i){const r=We[Ue(n)];let o=t[e];return o&&o instanceof r||(o=t[e]=new r,Object.assign(o,i)),o}function Je(t){const e=We[Ue(t.type)],n={};n.id=t.id,n.type=t.type,n.drawTime=t.drawTime,Object.assign(n,Ke(t,e.defaults),Ke(t,e.defaultRoutes));for(const i of Be)n[i]=t[i];return n}function Ke(t,e){const n={};for(const i of Object.keys(e)){const o=e[i],s=t[i];Ve(i)&&(0,r.b)(s)?n[i]=s.map((t=>Ye(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(i<n){const e=n-i;t.splice(i,0,...new Array(e))}else i>n&&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;e<o;e++)t[e]&&(n=i(t[e]))&&(r&&(r+=\" \"),r+=n)}else for(n in t)t[n]&&(r&&(r+=\" \"),r+=n);return r}function r(){for(var t,e,n=0,r=\"\",o=arguments.length;n<o;n++)(t=arguments[n])&&(e=i(t))&&(r&&(r+=\" \"),r+=e);return r}n.d(e,{W:function(){return r}})},424:function(t,e,n){n.d(e,{Z:function(){return st}});\n/*! @license DOMPurify 3.3.0 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.0/LICENSE */\nconst{entries:i,setPrototypeOf:r,isFrozen:o,getPrototypeOf:s,getOwnPropertyDescriptor:a}=Object;let{freeze:l,seal:c,create:u}=Object,{apply:h,construct:d}=\"undefined\"!==typeof Reflect&&Reflect;l||(l=function(t){return t}),c||(c=function(t){return t}),h||(h=function(t,e){for(var n=arguments.length,i=new Array(n>2?n-2:0),r=2;r<n;r++)i[r-2]=arguments[r];return t.apply(e,i)}),d||(d=function(t){for(var e=arguments.length,n=new Array(e>1?e-1:0),i=1;i<e;i++)n[i-1]=arguments[i];return new t(...n)});const f=D(Array.prototype.forEach),p=D(Array.prototype.lastIndexOf),g=D(Array.prototype.pop),m=D(Array.prototype.push),b=D(Array.prototype.splice),x=D(String.prototype.toLowerCase),y=D(String.prototype.toString),v=D(String.prototype.match),w=D(String.prototype.replace),k=D(String.prototype.indexOf),_=D(String.prototype.trim),M=D(Object.prototype.hasOwnProperty),S=D(RegExp.prototype.test),T=C(TypeError);function D(t){return function(e){e instanceof RegExp&&(e.lastIndex=0);for(var n=arguments.length,i=new Array(n>1?n-1:0),r=1;r<n;r++)i[r-1]=arguments[r];return h(t,e,i)}}function C(t){return function(){for(var e=arguments.length,n=new Array(e),i=0;i<e;i++)n[i]=arguments[i];return d(t,n)}}function A(t,e){let n=arguments.length>2&&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<t.length;e++){const n=M(t,e);n||(t[e]=null)}return t}function P(t){const e=u(null);for(const[n,r]of i(t)){const i=M(t,n);i&&(Array.isArray(r)?e[n]=O(r):r&&\"object\"===typeof r&&r.constructor===Object?e[n]=P(r):e[n]=r)}return e}function E(t,e){while(null!==t){const n=a(t,e);if(n){if(n.get)return D(n.get);if(\"function\"===typeof n.value)return D(n.value)}t=s(t)}function n(){return null}return n}const R=l([\"a\",\"abbr\",\"acronym\",\"address\",\"area\",\"article\",\"aside\",\"audio\",\"b\",\"bdi\",\"bdo\",\"big\",\"blink\",\"blockquote\",\"body\",\"br\",\"button\",\"canvas\",\"caption\",\"center\",\"cite\",\"code\",\"col\",\"colgroup\",\"content\",\"data\",\"datalist\",\"dd\",\"decorator\",\"del\",\"details\",\"dfn\",\"dialog\",\"dir\",\"div\",\"dl\",\"dt\",\"element\",\"em\",\"fieldset\",\"figcaption\",\"figure\",\"font\",\"footer\",\"form\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"head\",\"header\",\"hgroup\",\"hr\",\"html\",\"i\",\"img\",\"input\",\"ins\",\"kbd\",\"label\",\"legend\",\"li\",\"main\",\"map\",\"mark\",\"marquee\",\"menu\",\"menuitem\",\"meter\",\"nav\",\"nobr\",\"ol\",\"optgroup\",\"option\",\"output\",\"p\",\"picture\",\"pre\",\"progress\",\"q\",\"rp\",\"rt\",\"ruby\",\"s\",\"samp\",\"search\",\"section\",\"select\",\"shadow\",\"slot\",\"small\",\"source\",\"spacer\",\"span\",\"strike\",\"strong\",\"style\",\"sub\",\"summary\",\"sup\",\"table\",\"tbody\",\"td\",\"template\",\"textarea\",\"tfoot\",\"th\",\"thead\",\"time\",\"tr\",\"track\",\"tt\",\"u\",\"ul\",\"var\",\"video\",\"wbr\"]),I=l([\"svg\",\"a\",\"altglyph\",\"altglyphdef\",\"altglyphitem\",\"animatecolor\",\"animatemotion\",\"animatetransform\",\"circle\",\"clippath\",\"defs\",\"desc\",\"ellipse\",\"enterkeyhint\",\"exportparts\",\"filter\",\"font\",\"g\",\"glyph\",\"glyphref\",\"hkern\",\"image\",\"inputmode\",\"line\",\"lineargradient\",\"marker\",\"mask\",\"metadata\",\"mpath\",\"part\",\"path\",\"pattern\",\"polygon\",\"polyline\",\"radialgradient\",\"rect\",\"stop\",\"style\",\"switch\",\"symbol\",\"text\",\"textpath\",\"title\",\"tref\",\"tspan\",\"view\",\"vkern\"]),L=l([\"feBlend\",\"feColorMatrix\",\"feComponentTransfer\",\"feComposite\",\"feConvolveMatrix\",\"feDiffuseLighting\",\"feDisplacementMap\",\"feDistantLight\",\"feDropShadow\",\"feFlood\",\"feFuncA\",\"feFuncB\",\"feFuncG\",\"feFuncR\",\"feGaussianBlur\",\"feImage\",\"feMerge\",\"feMergeNode\",\"feMorphology\",\"feOffset\",\"fePointLight\",\"feSpecularLighting\",\"feSpotLight\",\"feTile\",\"feTurbulence\"]),z=l([\"animate\",\"color-profile\",\"cursor\",\"discard\",\"font-face\",\"font-face-format\",\"font-face-name\",\"font-face-src\",\"font-face-uri\",\"foreignobject\",\"hatch\",\"hatchpath\",\"mesh\",\"meshgradient\",\"meshpatch\",\"meshrow\",\"missing-glyph\",\"script\",\"set\",\"solidcolor\",\"unknown\",\"use\"]),N=l([\"math\",\"menclose\",\"merror\",\"mfenced\",\"mfrac\",\"mglyph\",\"mi\",\"mlabeledtr\",\"mmultiscripts\",\"mn\",\"mo\",\"mover\",\"mpadded\",\"mphantom\",\"mroot\",\"mrow\",\"ms\",\"mspace\",\"msqrt\",\"mstyle\",\"msub\",\"msup\",\"msubsup\",\"mtable\",\"mtd\",\"mtext\",\"mtr\",\"munder\",\"munderover\",\"mprescripts\"]),F=l([\"maction\",\"maligngroup\",\"malignmark\",\"mlongdiv\",\"mscarries\",\"mscarry\",\"msgroup\",\"mstack\",\"msline\",\"msrow\",\"semantics\",\"annotation\",\"annotation-xml\",\"mprescripts\",\"none\"]),j=l([\"#text\"]),H=l([\"accept\",\"action\",\"align\",\"alt\",\"autocapitalize\",\"autocomplete\",\"autopictureinpicture\",\"autoplay\",\"background\",\"bgcolor\",\"border\",\"capture\",\"cellpadding\",\"cellspacing\",\"checked\",\"cite\",\"class\",\"clear\",\"color\",\"cols\",\"colspan\",\"controls\",\"controlslist\",\"coords\",\"crossorigin\",\"datetime\",\"decoding\",\"default\",\"dir\",\"disabled\",\"disablepictureinpicture\",\"disableremoteplayback\",\"download\",\"draggable\",\"enctype\",\"enterkeyhint\",\"exportparts\",\"face\",\"for\",\"headers\",\"height\",\"hidden\",\"high\",\"href\",\"hreflang\",\"id\",\"inert\",\"inputmode\",\"integrity\",\"ismap\",\"kind\",\"label\",\"lang\",\"list\",\"loading\",\"loop\",\"low\",\"max\",\"maxlength\",\"media\",\"method\",\"min\",\"minlength\",\"multiple\",\"muted\",\"name\",\"nonce\",\"noshade\",\"novalidate\",\"nowrap\",\"open\",\"optimum\",\"part\",\"pattern\",\"placeholder\",\"playsinline\",\"popover\",\"popovertarget\",\"popovertargetaction\",\"poster\",\"preload\",\"pubdate\",\"radiogroup\",\"readonly\",\"rel\",\"required\",\"rev\",\"reversed\",\"role\",\"rows\",\"rowspan\",\"spellcheck\",\"scope\",\"selected\",\"shape\",\"size\",\"sizes\",\"slot\",\"span\",\"srclang\",\"start\",\"src\",\"srcset\",\"step\",\"style\",\"summary\",\"tabindex\",\"title\",\"translate\",\"type\",\"usemap\",\"valign\",\"value\",\"width\",\"wrap\",\"xmlns\",\"slot\"]),W=l([\"accent-height\",\"accumulate\",\"additive\",\"alignment-baseline\",\"amplitude\",\"ascent\",\"attributename\",\"attributetype\",\"azimuth\",\"basefrequency\",\"baseline-shift\",\"begin\",\"bias\",\"by\",\"class\",\"clip\",\"clippathunits\",\"clip-path\",\"clip-rule\",\"color\",\"color-interpolation\",\"color-interpolation-filters\",\"color-profile\",\"color-rendering\",\"cx\",\"cy\",\"d\",\"dx\",\"dy\",\"diffuseconstant\",\"direction\",\"display\",\"divisor\",\"dur\",\"edgemode\",\"elevation\",\"end\",\"exponent\",\"fill\",\"fill-opacity\",\"fill-rule\",\"filter\",\"filterunits\",\"flood-color\",\"flood-opacity\",\"font-family\",\"font-size\",\"font-size-adjust\",\"font-stretch\",\"font-style\",\"font-variant\",\"font-weight\",\"fx\",\"fy\",\"g1\",\"g2\",\"glyph-name\",\"glyphref\",\"gradientunits\",\"gradienttransform\",\"height\",\"href\",\"id\",\"image-rendering\",\"in\",\"in2\",\"intercept\",\"k\",\"k1\",\"k2\",\"k3\",\"k4\",\"kerning\",\"keypoints\",\"keysplines\",\"keytimes\",\"lang\",\"lengthadjust\",\"letter-spacing\",\"kernelmatrix\",\"kernelunitlength\",\"lighting-color\",\"local\",\"marker-end\",\"marker-mid\",\"marker-start\",\"markerheight\",\"markerunits\",\"markerwidth\",\"maskcontentunits\",\"maskunits\",\"max\",\"mask\",\"mask-type\",\"media\",\"method\",\"mode\",\"min\",\"name\",\"numoctaves\",\"offset\",\"operator\",\"opacity\",\"order\",\"orient\",\"orientation\",\"origin\",\"overflow\",\"paint-order\",\"path\",\"pathlength\",\"patterncontentunits\",\"patterntransform\",\"patternunits\",\"points\",\"preservealpha\",\"preserveaspectratio\",\"primitiveunits\",\"r\",\"rx\",\"ry\",\"radius\",\"refx\",\"refy\",\"repeatcount\",\"repeatdur\",\"restart\",\"result\",\"rotate\",\"scale\",\"seed\",\"shape-rendering\",\"slope\",\"specularconstant\",\"specularexponent\",\"spreadmethod\",\"startoffset\",\"stddeviation\",\"stitchtiles\",\"stop-color\",\"stop-opacity\",\"stroke-dasharray\",\"stroke-dashoffset\",\"stroke-linecap\",\"stroke-linejoin\",\"stroke-miterlimit\",\"stroke-opacity\",\"stroke\",\"stroke-width\",\"style\",\"surfacescale\",\"systemlanguage\",\"tabindex\",\"tablevalues\",\"targetx\",\"targety\",\"transform\",\"transform-origin\",\"text-anchor\",\"text-decoration\",\"text-rendering\",\"textlength\",\"type\",\"u1\",\"u2\",\"unicode\",\"values\",\"viewbox\",\"visibility\",\"version\",\"vert-adv-y\",\"vert-origin-x\",\"vert-origin-y\",\"width\",\"word-spacing\",\"wrap\",\"writing-mode\",\"xchannelselector\",\"ychannelselector\",\"x\",\"x1\",\"x2\",\"xmlns\",\"y\",\"y1\",\"y2\",\"z\",\"zoomandpan\"]),$=l([\"accent\",\"accentunder\",\"align\",\"bevelled\",\"close\",\"columnsalign\",\"columnlines\",\"columnspan\",\"denomalign\",\"depth\",\"dir\",\"display\",\"displaystyle\",\"encoding\",\"fence\",\"frame\",\"height\",\"href\",\"id\",\"largeop\",\"length\",\"linethickness\",\"lspace\",\"lquote\",\"mathbackground\",\"mathcolor\",\"mathsize\",\"mathvariant\",\"maxsize\",\"minsize\",\"movablelimits\",\"notation\",\"numalign\",\"open\",\"rowalign\",\"rowlines\",\"rowspacing\",\"rowspan\",\"rspace\",\"rquote\",\"scriptlevel\",\"scriptminsize\",\"scriptsizemultiplier\",\"selection\",\"separator\",\"separators\",\"stretchy\",\"subscriptshift\",\"supscriptshift\",\"symmetric\",\"voffset\",\"width\",\"xmlns\"]),B=l([\"xlink:href\",\"xml:id\",\"xlink:title\",\"xml:space\",\"xmlns:xlink\"]),Y=c(/\\{\\{[\\w\\W]*|[\\w\\W]*\\}\\}/gm),V=c(/<%[\\w\\W]*|[\\w\\W]*%>/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=\"<remove></remove>\"+t;else{const e=v(t,/^[\\r\\n\\t ]+/);i=e&&e[0]}\"application/xhtml+xml\"===ue&&ie===ne&&(t='<html xmlns=\"http://www.w3.org/1999/xhtml\"><head></head><body>'+t+\"</body></html>\");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=\"<!DOCTYPE \"+i.ownerDocument.doctype.name+\">\\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)(?<!1)\")}catch{return!1}})(),c={codeRemoveIndent:/^(?: {1,4}| {0,3}\\t)/gm,outputLinkReplace:/\\\\([\\[\\]])/g,indentCodeCompensation:/^(\\s+)(?:```)/,beginningSpace:/^\\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\\n/g,tabCharGlobal:/\\t/g,multipleSpaceGlobal:/\\s+/g,blankLine:/^[ \\t]*$/,doubleBlankLine:/\\n[ \\t]*\\n[ \\t]*$/,blockquoteStart:/^ {0,3}>/,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:/^<a /i,endATag:/^<\\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\\s|>)/i,endPreScriptTag:/^<\\/(pre|code|kbd|script)(\\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'\"]*[^\\s])\\s+(['\"])(.*)\\2/,unicodeAlphaNumeric:/[\\p{L}\\p{N}]/u,escapeTest:/[&<>\"']/,escapeReplace:/[&<>\"']/g,escapeTestNoEncode:/[<>\"']|&(?!(#\\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\\w+);)/,escapeReplaceNoEncode:/[<>\"']|&(?!(#\\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\\w+);)/g,unescapeTest:/&(#(?:\\d+)|(?:#x[0-9A-Fa-f]+)|(?:\\w+));?/gi,caret:/(^|[^\\[])\\^/g,percentDecode:/%25/g,findPipe:/\\|/g,splitPipe:/ \\|/,slashPipe:/\\\\\\|/g,carriageReturn:/\\r\\n|\\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\\S*/,endingNewline:/\\n$/,listItemRegex:t=>new RegExp(`^( {0,3}${t})((?:[\\t ][^\\\\n]*)?(?:\\\\n|$))`),nextBulletRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\\\d{1,9}[.)])((?:[ \\t][^\\\\n]*)?(?:\\\\n|$))`),hrRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\\\* *){3,})(?:\\\\n+|$)`),fencesBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\\`\\`\\`|~~~)`),headingBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),htmlBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,\"i\")},u=/^(?:[ \\t]*(?:\\n|$))+/,h=/^((?: {4}| {0,3}\\t)[^\\n]+(?:\\n(?:[ \\t]*(?:\\n|$))*)?)+/,d=/^ {0,3}(`{3,}(?=[^`\\n]*(?:\\n|$))|~{3,})([^\\n]*)(?:\\n|$)(?:|([\\s\\S]*?)(?:\\n|$))(?: {0,3}\\1[~`]* *(?=\\n|$)|$)/,f=/^ {0,3}((?:-[\\t ]*){3,}|(?:_[ \\t]*){3,}|(?:\\*[ \\t]*){3,})(?:\\n+|$)/,p=/^ {0,3}(#{1,6})(?=\\s|$)(.*)(?:\\n+|$)/,g=/(?:[*+-]|\\d{1,9}[.)])/,m=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\\n(?!\\s*?\\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\\n {0,3}(=+|-+) *(?:\\n+|$)/,b=a(m).replace(/bull/g,g).replace(/blockCode/g,/(?: {4}| {0,3}\\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\\n>]+>\\n/).replace(/\\|table/g,\"\").getRegex(),x=a(m).replace(/bull/g,g).replace(/blockCode/g,/(?: {4}| {0,3}\\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\\n>]+>\\n/).replace(/table/g,/ {0,3}\\|?(?:[:\\- ]*\\|)+[\\:\\- ]*\\n/).getRegex(),y=/^([^\\n]+(?:\\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\\n)[^\\n]+)*)/,v=/^[^\\n]+/,w=/(?!\\s*\\])(?:\\\\[\\s\\S]|[^\\[\\]\\\\])+/,k=a(/^ {0,3}\\[(label)\\]: *(?:\\n[ \\t]*)?([^<\\s][^\\s]*|<.*?>)(?:(?: +(?:\\n[ \\t]*)?| *\\n[ \\t]*)(title))? *(?:\\n+|$)/).replace(\"label\",w).replace(\"title\",/(?:\"(?:\\\\\"?|[^\"\\\\])*\"|'[^'\\n]*(?:\\n[^'\\n]+)*\\n?'|\\([^()]*\\))/).getRegex(),_=a(/^( {0,3}bull)([ \\t][^\\n]+?)?(?:\\n|$)/).replace(/bull/g,g).getRegex(),M=\"address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul\",S=/<!--(?:-?>|[\\s\\S]*?(?:-->|$))/,T=a(\"^ {0,3}(?:<(script|pre|style|textarea)[\\\\s>][\\\\s\\\\S]*?(?:</\\\\1>[^\\\\n]*\\\\n+|$)|comment[^\\\\n]*(\\\\n+|$)|<\\\\?[\\\\s\\\\S]*?(?:\\\\?>\\\\n*|$)|<![A-Z][\\\\s\\\\S]*?(?:>\\\\n*|$)|<!\\\\[CDATA\\\\[[\\\\s\\\\S]*?(?:\\\\]\\\\]>\\\\n*|$)|</?(tag)(?: +|\\\\n|/?>)[\\\\s\\\\S]*?(?:(?:\\\\n[ \\t]*)+\\\\n|$)|<(?!script|pre|style|textarea)([a-z][\\\\w-]*)(?:attribute)*? */?>(?=[ \\\\t]*(?:\\\\n|$))[\\\\s\\\\S]*?(?:(?:\\\\n[ \\t]*)+\\\\n|$)|</(?!script|pre|style|textarea)[a-z][\\\\w-]*\\\\s*>(?=[ \\\\t]*(?:\\\\n|$))[\\\\s\\\\S]*?(?:(?:\\\\n[ \\t]*)+\\\\n|$))\",\"i\").replace(\"comment\",S).replace(\"tag\",M).replace(\"attribute\",/ +[a-zA-Z:_][\\w.:-]*(?: *= *\"[^\"\\n]*\"| *= *'[^'\\n]*'| *= *[^\\s\"'=<>`]+)?/).getRegex(),D=a(y).replace(\"hr\",f).replace(\"heading\",\" {0,3}#{1,6}(?:\\\\s|$)\").replace(\"|lheading\",\"\").replace(\"|table\",\"\").replace(\"blockquote\",\" {0,3}>\").replace(\"fences\",\" {0,3}(?:`{3,}(?=[^`\\\\n]*\\\\n)|~{3,})[^\\\\n]*\\\\n\").replace(\"list\",\" {0,3}(?:[*+-]|1[.)]) \").replace(\"html\",\"</?(?:tag)(?: +|\\\\n|/?>)|<(?:script|pre|style|textarea|!--)\").replace(\"tag\",M).getRegex(),C=a(/^( {0,3}> ?(paragraph|[^\\n]*)(?:\\n|$))+/).replace(\"paragraph\",D).getRegex(),A={blockquote:C,code:h,def:k,fences:d,heading:p,hr:f,html:T,lheading:b,list:_,newline:u,paragraph:D,table:s,text:v},O=a(\"^ *([^\\\\n ].*)\\\\n {0,3}((?:\\\\| *)?:?-+:? *(?:\\\\| *:?-+:? *)*(?:\\\\| *)?)(?:\\\\n((?:(?! *\\\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\\\n|$))*)\\\\n*|$)\").replace(\"hr\",f).replace(\"heading\",\" {0,3}#{1,6}(?:\\\\s|$)\").replace(\"blockquote\",\" {0,3}>\").replace(\"code\",\"(?: {4}| {0,3}\\t)[^\\\\n]\").replace(\"fences\",\" {0,3}(?:`{3,}(?=[^`\\\\n]*\\\\n)|~{3,})[^\\\\n]*\\\\n\").replace(\"list\",\" {0,3}(?:[*+-]|1[.)]) \").replace(\"html\",\"</?(?:tag)(?: +|\\\\n|/?>)|<(?:script|pre|style|textarea|!--)\").replace(\"tag\",M).getRegex(),P={...A,lheading:x,table:O,paragraph:a(y).replace(\"hr\",f).replace(\"heading\",\" {0,3}#{1,6}(?:\\\\s|$)\").replace(\"|lheading\",\"\").replace(\"table\",O).replace(\"blockquote\",\" {0,3}>\").replace(\"fences\",\" {0,3}(?:`{3,}(?=[^`\\\\n]*\\\\n)|~{3,})[^\\\\n]*\\\\n\").replace(\"list\",\" {0,3}(?:[*+-]|1[.)]) \").replace(\"html\",\"</?(?:tag)(?: +|\\\\n|/?>)|<(?:script|pre|style|textarea|!--)\").replace(\"tag\",M).getRegex()},E={...A,html:a(\"^ *(?:comment *(?:\\\\n|\\\\s*$)|<(tag)[\\\\s\\\\S]+?</\\\\1> *(?:\\\\n{2,}|\\\\s*$)|<tag(?:\\\"[^\\\"]*\\\"|'[^']*'|\\\\s[^'\\\"/>\\\\s]*)*?/?> *(?:\\\\n{2,}|\\\\s*$))\").replace(\"comment\",S).replace(/tag/g,\"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\\\b)\\\\w+(?!:|[^\\\\w\\\\s@]*@)\\\\b\").getRegex(),def:/^ *\\[([^\\]]+)\\]: *<?([^\\s>]+)>?(?: +([\"(][^\\n]+[\")]))? *(?:\\n+|$)/,heading:/^(#{1,6})(.*)(?:\\n+|$)/,fences:s,lheading:/^(.+?)\\n {0,3}(=+|-+) *(?:\\n+|$)/,paragraph:a(y).replace(\"hr\",f).replace(\"heading\",\" *#{1,6} *[^\\n]\").replace(\"lheading\",b).replace(\"|table\",\"\").replace(\"blockquote\",\" {0,3}>\").replace(\"|fences\",\"\").replace(\"|list\",\"\").replace(\"|html\",\"\").replace(\"|tag\",\"\").getRegex()},R=/^\\\\([!\"#$%&'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])/,I=/^(`+)([^`]|[^`][\\s\\S]*?[^`])\\1(?!`)/,L=/^( {2,}|\\\\)\\n(?!\\s*$)/,z=/^(`+|[^`])(?:(?= {2,}\\n)|[\\s\\S]*?(?:(?=[\\\\<!\\[`*_]|\\b_|$)|[^ ](?= {2,}\\n)))/,N=/[\\p{P}\\p{S}]/u,F=/[\\s\\p{P}\\p{S}]/u,j=/[^\\s\\p{P}\\p{S}]/u,H=a(/^((?![*_])punctSpace)/,\"u\").replace(/punctSpace/g,F).getRegex(),W=/(?!~)[\\p{P}\\p{S}]/u,$=/(?!~)[\\s\\p{P}\\p{S}]/u,B=/(?:[^\\s\\p{P}\\p{S}]|~)/u,Y=a(/link|precode-code|html/,\"g\").replace(\"link\",/\\[(?:[^\\[\\]`]|(?<a>`+)[^`]+\\k<a>(?!`))*?\\]\\((?:\\\\[\\s\\S]|[^\\\\\\(\\)]|\\((?:\\\\[\\s\\S]|[^\\\\\\(\\)])*\\))*\\)/).replace(\"precode-\",l?\"(?<!`)()\":\"(^^|[^`])\").replace(\"code\",/(?<b>`+)[^`]+\\k<b>(?!`)/).replace(\"html\",/<(?! )[^<>]*?>/).getRegex(),V=/^(?:\\*+(?:((?!\\*)punct)|[^\\s*]))|^_+(?:((?!_)punct)|([^\\s_]))/,U=a(V,\"u\").replace(/punct/g,N).getRegex(),q=a(V,\"u\").replace(/punct/g,W).getRegex(),X=\"^[^_*]*?__[^_*]*?\\\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\\\*)punct(\\\\*+)(?=[\\\\s]|$)|notPunctSpace(\\\\*+)(?!\\\\*)(?=punctSpace|$)|(?!\\\\*)punctSpace(\\\\*+)(?=notPunctSpace)|[\\\\s](\\\\*+)(?!\\\\*)(?=punct)|(?!\\\\*)punct(\\\\*+)(?!\\\\*)(?=punct)|notPunctSpace(\\\\*+)(?=notPunctSpace)\",G=a(X,\"gu\").replace(/notPunctSpace/g,j).replace(/punctSpace/g,F).replace(/punct/g,N).getRegex(),Z=a(X,\"gu\").replace(/notPunctSpace/g,B).replace(/punctSpace/g,$).replace(/punct/g,W).getRegex(),Q=a(\"^[^_*]*?\\\\*\\\\*[^_*]*?_[^_*]*?(?=\\\\*\\\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)\",\"gu\").replace(/notPunctSpace/g,j).replace(/punctSpace/g,F).replace(/punct/g,N).getRegex(),J=a(/\\\\(punct)/,\"gu\").replace(/punct/g,N).getRegex(),K=a(/^<(scheme:[^\\s\\x00-\\x1f<>]*|email)>/).replace(\"scheme\",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace(\"email\",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),tt=a(S).replace(\"(?:--\\x3e|$)\",\"--\\x3e\").getRegex(),et=a(\"^comment|^</[a-zA-Z][\\\\w:-]*\\\\s*>|^<[a-zA-Z][\\\\w-]*(?:attribute)*?\\\\s*/?>|^<\\\\?[\\\\s\\\\S]*?\\\\?>|^<![a-zA-Z]+\\\\s[\\\\s\\\\S]*?>|^<!\\\\[CDATA\\\\[[\\\\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]*?(?:(?=[\\\\<!\\[`*~_]|\\b_|protocol:\\/\\/|www\\.|$)|[^ ](?= {2,}\\n)|[^a-zA-Z0-9.!#$%&'*+\\/=?_`{\\|}~-](?=[a-zA-Z0-9.!#$%&'*+\\/=?_`{\\|}~-]+@)))/).replace(\"protocol\",at).getRegex()},ht={...ut,br:a(L).replace(\"{2,}\",\"*\").getRegex(),text:a(ut.text).replace(\"\\\\b_\",\"\\\\b_| {2,}\\\\n\").replace(/\\{2,\\}/g,\"*\").getRegex()},dt={normal:A,gfm:P,pedantic:E},ft={normal:lt,gfm:ut,breaks:ht,pedantic:ct},pt={\"&\":\"&amp;\",\"<\":\"&lt;\",\">\":\"&gt;\",'\"':\"&quot;\",\"'\":\"&#39;\"},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.length<e;)i.push(\"\");for(;r<i.length;r++)i[r]=i[r].trim().replace(c.slashPipe,\"|\");return i}function yt(t,e,n){let i=t.length;if(0===i)return\"\";let r=0;for(;r<i;){let o=t.charAt(i-r-1);if(o!==e||n){if(o===e||!n)break;r++}else r++}return t.slice(0,i-r)}function vt(t,e){if(-1===t.indexOf(e[1]))return-1;let n=0;for(let i=0;i<t.length;i++)if(\"\\\\\"===t[i])i++;else if(t[i]===e[0])n++;else if(t[i]===e[1]&&(n--,n<0))return i;return n>0?-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;e<t.length;e++)if(this.rules.other.blockquoteStart.test(t[e]))s.push(t[e]),o=!0;else{if(o)break;s.push(t[e])}t=t.slice(e);let a=s.join(\"\\n\"),l=a.replace(this.rules.other.blockquoteSetextReplace,\"\\n    $1\").replace(this.rules.other.blockquoteSetextReplace2,\"\");n=n?`${n}\\n${a}`:a,i=i?`${i}\\n${l}`:l;let c=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTokens(l,r,!0),this.lexer.state.top=c,0===t.length)break;let u=r.at(-1);if(\"code\"===u?.type)break;if(\"blockquote\"===u?.type){let e=u,o=e.raw+\"\\n\"+t.join(\"\\n\"),s=this.blockquote(o);r[r.length-1]=s,n=n.substring(0,n.length-e.raw.length)+s.raw,i=i.substring(0,i.length-e.text.length)+s.text;break}if(\"list\"!==u?.type);else{let e=u,o=e.raw+\"\\n\"+t.join(\"\\n\"),s=this.list(o);r[r.length-1]=s,n=n.substring(0,n.length-u.raw.length)+s.raw,i=i.substring(0,i.length-e.raw.length)+s.raw,t=o.substring(r.at(-1).raw.length).split(\"\\n\")}}return{type:\"blockquote\",raw:n,tokens:r,text:i}}}list(t){let e=this.rules.block.list.exec(t);if(e){let n=e[1].trim(),i=n.length>1,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<r.items.length;t++)if(this.lexer.state.top=!1,r.items[t].tokens=this.lexer.blockTokens(r.items[t].text,[]),!r.loose){let e=r.items[t].tokens.filter((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<r.items.length;t++)r.items[t].loose=!0;return r}}html(t){let e=this.rules.block.html.exec(t);if(e)return{type:\"html\",block:!0,raw:e[0],pre:\"pre\"===e[1]||\"script\"===e[1]||\"style\"===e[1],text:e[0]}}def(t){let e=this.rules.block.def.exec(t);if(e){let t=e[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal,\" \"),n=e[2]?e[2].replace(this.rules.other.hrefBrackets,\"$1\").replace(this.rules.inline.anyPunctuation,\"$1\"):\"\",i=e[3]?e[3].substring(1,e[3].length-1).replace(this.rules.inline.anyPunctuation,\"$1\"):e[3];return{type:\"def\",tag:t,raw:e[0],href:n,title:i}}}table(t){let e=this.rules.block.table.exec(t);if(!e||!this.rules.other.tableDelimiter.test(e[2]))return;let n=xt(e[1]),i=e[2].replace(this.rules.other.tableAlignChars,\"\").split(\"|\"),r=e[3]?.trim()?e[3].replace(this.rules.other.tableRowBlankLine,\"\").split(\"\\n\"):[],o={type:\"table\",raw:e[0],header:[],align:[],rows:[]};if(n.length===i.length){for(let t of i)this.rules.other.tableAlignRight.test(t)?o.align.push(\"right\"):this.rules.other.tableAlignCenter.test(t)?o.align.push(\"center\"):this.rules.other.tableAlignLeft.test(t)?o.align.push(\"left\"):o.align.push(null);for(let t=0;t<n.length;t++)o.header.push({text:n[t],tokens:this.lexer.inline(n[t]),header:!0,align:o.align[t]});for(let t of r)o.rows.push(xt(t,o.header.length).map(((t,e)=>({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<this.inlineQueue.length;e++){let t=this.inlineQueue[e];this.inlineTokens(t.src,t.tokens)}return this.inlineQueue=[],this.tokens}blockTokens(t,e=[],n=!1){for(this.options.pedantic&&(t=t.replace(c.tabCharGlobal,\"    \").replace(c.spaceLine,\"\"));t;){let i;if(this.options.extensions?.block?.some((n=>!!(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?'<pre><code class=\"language-'+mt(i)+'\">'+(n?r:mt(r,!0))+\"</code></pre>\\n\":\"<pre><code>\"+(n?r:mt(r,!0))+\"</code></pre>\\n\"}blockquote({tokens:t}){return`<blockquote>\\n${this.parser.parse(t)}</blockquote>\\n`}html({text:t}){return t}def(t){return\"\"}heading({tokens:t,depth:e}){return`<h${e}>${this.parser.parseInline(t)}</h${e}>\\n`}hr(t){return\"<hr>\\n\"}list(t){let e=t.ordered,n=t.start,i=\"\";for(let s=0;s<t.items.length;s++){let e=t.items[s];i+=this.listitem(e)}let r=e?\"ol\":\"ul\",o=e&&1!==n?' start=\"'+n+'\"':\"\";return\"<\"+r+o+\">\\n\"+i+\"</\"+r+\">\\n\"}listitem(t){let e=\"\";if(t.task){let n=this.checkbox({checked:!!t.checked});t.loose?\"paragraph\"===t.tokens[0]?.type?(t.tokens[0].text=n+\" \"+t.tokens[0].text,t.tokens[0].tokens&&t.tokens[0].tokens.length>0&&\"text\"===t.tokens[0].tokens[0].type&&(t.tokens[0].tokens[0].text=n+\" \"+mt(t.tokens[0].tokens[0].text),t.tokens[0].tokens[0].escaped=!0)):t.tokens.unshift({type:\"text\",raw:n+\" \",text:n+\" \",escaped:!0}):e+=n+\" \"}return e+=this.parser.parse(t.tokens,!!t.loose),`<li>${e}</li>\\n`}checkbox({checked:t}){return\"<input \"+(t?'checked=\"\" ':\"\")+'disabled=\"\" type=\"checkbox\">'}paragraph({tokens:t}){return`<p>${this.parser.parseInline(t)}</p>\\n`}table(t){let e=\"\",n=\"\";for(let r=0;r<t.header.length;r++)n+=this.tablecell(t.header[r]);e+=this.tablerow({text:n});let i=\"\";for(let r=0;r<t.rows.length;r++){let e=t.rows[r];n=\"\";for(let t=0;t<e.length;t++)n+=this.tablecell(e[t]);i+=this.tablerow({text:n})}return i&&(i=`<tbody>${i}</tbody>`),\"<table>\\n<thead>\\n\"+e+\"</thead>\\n\"+i+\"</table>\\n\"}tablerow({text:t}){return`<tr>\\n${t}</tr>\\n`}tablecell(t){let e=this.parser.parseInline(t.tokens),n=t.header?\"th\":\"td\";return(t.align?`<${n} align=\"${t.align}\">`:`<${n}>`)+e+`</${n}>\\n`}strong({tokens:t}){return`<strong>${this.parser.parseInline(t)}</strong>`}em({tokens:t}){return`<em>${this.parser.parseInline(t)}</em>`}codespan({text:t}){return`<code>${mt(t,!0)}</code>`}br(t){return\"<br>\"}del({tokens:t}){return`<del>${this.parser.parseInline(t)}</del>`}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='<a href=\"'+t+'\"';return e&&(o+=' title=\"'+mt(e)+'\"'),o+=\">\"+i+\"</a>\",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=`<img src=\"${t}\" alt=\"${n}\"`;return e&&(o+=` title=\"${mt(e)}\"`),o+=\">\",o}text(t){return\"tokens\"in t&&t.tokens?this.parser.parseInline(t.tokens):\"escaped\"in t&&t.escaped?t.text:mt(t.text)}},Tt=class{strong({text:t}){return t}em({text:t}){return t}codespan({text:t}){return t}del({text:t}){return t}html({text:t}){return t}text({text:t}){return t}link({text:t}){return\"\"+t}image({text:t}){return\"\"+t}br(){return\"\"}},Dt=class t{options;renderer;textRenderer;constructor(t){this.options=t||r,this.options.renderer=this.options.renderer||new St,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new Tt}static parse(e,n){return new t(n).parse(e)}static parseInline(e,n){return new t(n).parseInline(e)}parse(t,e=!0){let n=\"\";for(let i=0;i<t.length;i++){let r=t[i];if(this.options.extensions?.renderers?.[r.type]){let t=r,e=this.options.extensions.renderers[t.type].call({parser:this},t);if(!1!==e||![\"space\",\"hr\",\"heading\",\"code\",\"table\",\"blockquote\",\"list\",\"html\",\"def\",\"paragraph\",\"text\"].includes(t.type)){n+=e||\"\";continue}}let o=r;switch(o.type){case\"space\":n+=this.renderer.space(o);continue;case\"hr\":n+=this.renderer.hr(o);continue;case\"heading\":n+=this.renderer.heading(o);continue;case\"code\":n+=this.renderer.code(o);continue;case\"table\":n+=this.renderer.table(o);continue;case\"blockquote\":n+=this.renderer.blockquote(o);continue;case\"list\":n+=this.renderer.list(o);continue;case\"html\":n+=this.renderer.html(o);continue;case\"def\":n+=this.renderer.def(o);continue;case\"paragraph\":n+=this.renderer.paragraph(o);continue;case\"text\":{let r=o,s=this.renderer.text(r);for(;i+1<t.length&&\"text\"===t[i+1].type;)r=t[++i],s+=\"\\n\"+this.renderer.text(r);n+=e?this.renderer.paragraph({type:\"paragraph\",raw:s,text:s,tokens:[{type:\"text\",raw:s,text:s,escaped:!0}]}):s;continue}default:{let t='Token with \"'+o.type+'\" type was not found.';if(this.options.silent)return console.error(t),\"\";throw new Error(t)}}}return n}parseInline(t,e=this.renderer){let n=\"\";for(let i=0;i<t.length;i++){let r=t[i];if(this.options.extensions?.renderers?.[r.type]){let t=this.options.extensions.renderers[r.type].call({parser:this},r);if(!1!==t||![\"escape\",\"html\",\"link\",\"image\",\"strong\",\"em\",\"codespan\",\"br\",\"del\",\"text\"].includes(r.type)){n+=t||\"\";continue}}let o=r;switch(o.type){case\"escape\":n+=e.text(o);break;case\"html\":n+=e.html(o);break;case\"link\":n+=e.link(o);break;case\"image\":n+=e.image(o);break;case\"strong\":n+=e.strong(o);break;case\"em\":n+=e.em(o);break;case\"codespan\":n+=e.codespan(o);break;case\"br\":n+=e.br(o);break;case\"del\":n+=e.del(o);break;case\"text\":n+=e.text(o);break;default:{let t='Token with \"'+o.type+'\" type was not found.';if(this.options.silent)return console.error(t),\"\";throw new Error(t)}}}return n}},Ct=class{options;block;constructor(t){this.options=t||r}static passThroughHooks=new Set([\"preprocess\",\"postprocess\",\"processAllTokens\",\"emStrongMask\"]);static passThroughHooksRespectAsync=new Set([\"preprocess\",\"postprocess\",\"processAllTokens\"]);preprocess(t){return t}postprocess(t){return t}processAllTokens(t){return t}emStrongMask(t){return t}provideLexer(){return this.block?Mt.lex:Mt.lexInline}provideParser(){return this.block?Dt.parse:Dt.parseInline}},At=class{defaults=i();options=this.setOptions;parse=this.parseMarkdown(!0);parseInline=this.parseMarkdown(!1);Parser=Dt;Renderer=St;TextRenderer=Tt;Lexer=Mt;Tokenizer=_t;Hooks=Ct;constructor(...t){this.use(...t)}walkTokens(t,e){let n=[];for(let i of t)switch(n=n.concat(e.call(this,i)),i.type){case\"table\":{let t=i;for(let i of t.header)n=n.concat(this.walkTokens(i.tokens,e));for(let i of t.rows)for(let t of i)n=n.concat(this.walkTokens(t.tokens,e));break}case\"list\":{let t=i;n=n.concat(this.walkTokens(t.items,e));break}default:{let t=i;this.defaults.extensions?.childTokens?.[t.type]?this.defaults.extensions.childTokens[t.type].forEach((i=>{let r=t[i].flat(1/0);n=n.concat(this.walkTokens(r,e))})):t.tokens&&(n=n.concat(this.walkTokens(t.tokens,e)))}}return n}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach((t=>{let n={...t};if(n.async=this.defaults.async||n.async||!1,t.extensions&&(t.extensions.forEach((t=>{if(!t.name)throw new Error(\"extension name required\");if(\"renderer\"in t){let n=e.renderers[t.name];e.renderers[t.name]=n?function(...e){let i=t.renderer.apply(this,e);return!1===i&&(i=n.apply(this,e)),i}:t.renderer}if(\"tokenizer\"in t){if(!t.level||\"block\"!==t.level&&\"inline\"!==t.level)throw new Error(\"extension level must be 'block' or 'inline'\");let n=e[t.level];n?n.unshift(t.tokenizer):e[t.level]=[t.tokenizer],t.start&&(\"block\"===t.level?e.startBlock?e.startBlock.push(t.start):e.startBlock=[t.start]:\"inline\"===t.level&&(e.startInline?e.startInline.push(t.start):e.startInline=[t.start]))}\"childTokens\"in t&&t.childTokens&&(e.childTokens[t.name]=t.childTokens)})),n.extensions=e),t.renderer){let e=this.defaults.renderer||new St(this.defaults);for(let n in t.renderer){if(!(n in e))throw new Error(`renderer '${n}' does not exist`);if([\"options\",\"parser\"].includes(n))continue;let i=n,r=t.renderer[i],o=e[i];e[i]=(...t)=>{let n=r.apply(e,t);return!1===n&&(n=o.apply(e,t)),n||\"\"}}n.renderer=e}if(t.tokenizer){let e=this.defaults.tokenizer||new _t(this.defaults);for(let n in t.tokenizer){if(!(n in e))throw new Error(`tokenizer '${n}' does not exist`);if([\"options\",\"rules\",\"lexer\"].includes(n))continue;let i=n,r=t.tokenizer[i],o=e[i];e[i]=(...t)=>{let n=r.apply(e,t);return!1===n&&(n=o.apply(e,t)),n}}n.tokenizer=e}if(t.hooks){let e=this.defaults.hooks||new Ct;for(let n in t.hooks){if(!(n in e))throw new Error(`hook '${n}' does not exist`);if([\"options\",\"block\"].includes(n))continue;let i=n,r=t.hooks[i],o=e[i];Ct.passThroughHooks.has(n)?e[i]=t=>{if(this.defaults.async&&Ct.passThroughHooksRespectAsync.has(n))return(async()=>{let n=await r.call(e,t);return o.call(e,n)})();let i=r.call(e,t);return o.call(e,i)}:e[i]=(...t)=>{if(this.defaults.async)return(async()=>{let n=await r.apply(e,t);return!1===n&&(n=await o.apply(e,t)),n})();let n=r.apply(e,t);return!1===n&&(n=o.apply(e,t)),n}}n.hooks=e}if(t.walkTokens){let e=this.defaults.walkTokens,i=t.walkTokens;n.walkTokens=function(t){let n=[];return n.push(i.call(this,t)),e&&(n=n.concat(e.call(this,t))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return Mt.lex(t,e??this.defaults)}parser(t,e){return Dt.parse(t,e??this.defaults)}parseMarkdown(t){return(e,n)=>{let i={...n},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===i.async)return o(new Error(\"marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.\"));if(typeof e>\"u\"||null===e)return o(new Error(\"marked(): input parameter is undefined or null\"));if(\"string\"!=typeof e)return o(new Error(\"marked(): input parameter is of type \"+Object.prototype.toString.call(e)+\", string expected\"));if(r.hooks&&(r.hooks.options=r,r.hooks.block=t),r.async)return(async()=>{let n=r.hooks?await r.hooks.preprocess(e):e,i=await(r.hooks?await r.hooks.provideLexer():t?Mt.lex:Mt.lexInline)(n,r),o=r.hooks?await r.hooks.processAllTokens(i):i;r.walkTokens&&await Promise.all(this.walkTokens(o,r.walkTokens));let s=await(r.hooks?await r.hooks.provideParser():t?Dt.parse:Dt.parseInline)(o,r);return r.hooks?await r.hooks.postprocess(s):s})().catch(o);try{r.hooks&&(e=r.hooks.preprocess(e));let n=(r.hooks?r.hooks.provideLexer():t?Mt.lex:Mt.lexInline)(e,r);r.hooks&&(n=r.hooks.processAllTokens(n)),r.walkTokens&&this.walkTokens(n,r.walkTokens);let i=(r.hooks?r.hooks.provideParser():t?Dt.parse:Dt.parseInline)(n,r);return r.hooks&&(i=r.hooks.postprocess(i)),i}catch(s){return o(s)}}}onError(t,e){return n=>{if(n.message+=\"\\nPlease report this to https://github.com/markedjs/marked.\",t){let t=\"<p>An error occurred:</p><pre>\"+mt(n.message+\"\",!0)+\"</pre>\";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;u<t.length;u++){let s=t[u];if(0===i&&0===r){if(s===p){e.push(t.slice(o,u)),o=u+g;continue}if(\"/\"===s){n=u;continue}}\"[\"===s?i++:\"]\"===s?i--:\"(\"===s?r++:\")\"===s&&r--}const s=0===e.length?t:t.substring(o),a=b(s),l=a!==s,c=n&&n>o?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;n<v.length;++n){const t=v[n];s.push(x+t)}l=t+(l.length>0?\" \"+l:l)}return l};function k(){let t,e,n=0,i=\"\";while(n<arguments.length)(t=arguments[n++])&&(e=_(t))&&(i&&(i+=\" \"),i+=e);return i}const _=t=>{if(\"string\"===typeof t)return t;let e,n=\"\";for(let i=0;i<t.length;i++)t[i]&&(e=_(t[i]))&&(n&&(n+=\" \"),n+=e);return n};function M(t,...e){let n,i,r,o=s;function s(s){const l=e.reduce(((t,e)=>e(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);\n/*!\n  * vue-router v4.5.1\n  * (c) 2025 Eduardo San Martin Morote\n  * @license MIT\n  */\nconst 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<l&&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;o<i.length;o++)if(s=i[o],\".\"!==s){if(\"..\"!==s)break;a>1&&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;e<l.length;e++){const i=l[e];let s=40+(n.sensitive?.25:0);if(0===i.type)e||(r+=\"/\"),r+=i.value.replace(bt,\"\\\\$&\"),s+=40;else if(1===i.type){const{value:t,repeatable:n,optional:a,regexp:c}=i;o.push({name:t,repeatable:n,optional:a});const h=c||gt;if(h!==gt){s+=10;try{new RegExp(`(${h})`)}catch(u){throw new Error(`Invalid custom RegExp for param \"${t}\" (${h}): `+u.message)}}let d=n?`((?:${h})(?:/(?:${h}))*)`:`(${h})`;e||(d=a&&l.length<2?`(?:/${d})`:\"/\"+d),a&&(d+=\"?\"),r+=d,s+=20,a&&(s+=-8),n&&(s+=-20),\".*\"===h&&(s+=-50)}t.push(s)}i.push(t)}if(n.strict&&n.end){const t=i.length-1;i[t][i[t].length-1]+=.7000000000000001}n.strict||(r+=\"/?\"),n.end?r+=\"$\":n.strict&&!r.endsWith(\"/\")&&(r+=\"(?:/|$)\");const s=new RegExp(r,n.sensitive?\"\":\"i\");function a(t){const e=t.match(s),n={};if(!e)return null;for(let i=1;i<e.length;i++){const t=e[i]||\"\",r=o[i-1];n[r.name]=t&&r.repeatable?t.split(\"/\"):t}return n}function c(e){let n=\"\",i=!1;for(const r of t){i&&n.endsWith(\"/\")||(n+=\"/\"),i=!1;for(const t of r)if(0===t.type)n+=t.value;else if(1===t.type){const{value:o,repeatable:s,optional:a}=t,l=o in e?e[o]:\"\";if(h(l)&&!s)throw new Error(`Provided param \"${o}\" is an array but it is not repeatable (* or + modifiers)`);const c=h(l)?l.join(\"/\"):l;if(!c){if(!a)throw new Error(`Missing required param \"${o}\"`);r.length<2&&(n.endsWith(\"/\")?n=n.slice(0,-1):i=!0)}n+=c}}return n||\"/\"}return{re:s,score:i,keys:o,parse:a,stringify:c}}function yt(t,e){let n=0;while(n<t.length&&n<e.length){const i=e[n]-t[n];if(i)return i;n++}return t.length<e.length?1===t.length&&80===t[0]?-1:1:t.length>e.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(n<i.length&&n<r.length){const t=yt(i[n],r[n]);if(t)return t;n++}if(1===Math.abs(r.length-i.length)){if(wt(i))return 1;if(wt(r))return-1}return r.length-i.length}function wt(t){const e=t[t.length-1];return t.length>0&&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<t.length)if(a=t[l++],\"\\\\\"!==a||2===n)switch(n){case 0:\"/\"===a?(c&&h(),s()):\":\"===a?(h(),n=1):d();break;case 4:d(),n=i;break;case 1:\"(\"===a?n=2:_t.test(a)?d():(h(),n=0,\"*\"!==a&&\"?\"!==a&&\"+\"!==a&&l--);break;case 2:\")\"===a?\"\\\\\"==u[u.length-1]?u=u.slice(0,-1)+a:n=3:u+=a;break;case 3:h(),n=0,\"*\"!==a&&\"?\"!==a&&\"+\"!==a&&l--,u=\"\";break;default:e(\"Unknown state\");break}else i=n,n=4;return 2===n&&e(`Unfinished custom RegExp for param \"${c}\"`),h(),s(),r}function St(t,e,n){const i=xt(Mt(t.path),n);const r=l(i,{record:t,parent:e,children:[],alias:[]});return e&&!r.record.aliasOf===!e.record.aliasOf&&e.children.push(r),r}function Tt(t,e){const n=[],i=new Map;function r(t){return i.get(t)}function o(t,n,i){const r=!i,a=Ct(t);a.aliasOf=i&&i.record;const h=Et(e,t),d=[a];if(\"alias\"in t){const e=\"string\"===typeof t.alias?[t.alias]:t.alias;for(const t of e)d.push(Ct(l({},a,{components:i?i.record.components:a.components,path:t,aliasOf:i?i.record:a})))}let f,p;for(const e of d){const{path:l}=e;if(n&&\"/\"!==l[0]){const t=n.record.path,i=\"/\"===t[t.length-1]?\"\":\"/\";e.path=n.record.path+(l&&i+l)}if(f=St(e,n,h),i?i.alias.push(f):(p=p||f,p!==f&&p.alias.push(f),r&&t.name&&!Ot(f)&&s(t.name)),Lt(f)&&c(f),a.children){const t=a.children;for(let e=0;e<t.length;e++)o(t[e],f,i&&i.children[e])}i=i||f}return p?()=>{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;r<i.length;++r){const t=i[r].replace(b,\" \"),n=t.indexOf(\"=\"),o=E(n<0?t:t.slice(0,n)),s=n<0?null:E(t.slice(n+1));if(o in e){let t=e[o];h(t)||(t=e[o]=[t]),t.push(s)}else e[o]=s}return e}function Nt(t){let e=\"\";for(let n in t){const i=t[n];if(n=A(n),null==i){void 0!==i&&(e+=(e.length?\"&\":\"\")+n);continue}const r=h(i)?i.map((t=>t&&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;s<o;s++){const o=e.matched[s];o&&(t.matched.find((t=>j(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)}}}]);"
  },
  {
    "path": "web/static/manifest.json",
    "content": "{\n  \"id\": \"gatus\",\n  \"name\": \"Gatus\",\n  \"short_name\": \"Gatus\",\n  \"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\",\n  \"lang\": \"en\",\n  \"scope\": \"/\",\n  \"start_url\": \"/\",\n  \"theme_color\": \"#f7f9fb\",\n  \"background_color\": \"#f7f9fb\",\n  \"display\": \"standalone\",\n  \"icons\": [\n    {\n      \"src\": \"/logo-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/logo-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    }\n  ]\n}\n"
  },
  {
    "path": "web/static.go",
    "content": "package static\n\nimport \"embed\"\n\nvar (\n\t//go:embed static\n\tFileSystem embed.FS\n)\n\nconst (\n\tRootPath  = \"static\"\n\tIndexPath = RootPath + \"/index.html\"\n)\n"
  },
  {
    "path": "web/static_test.go",
    "content": "package static\n\nimport (\n\t\"io/fs\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestEmbed(t *testing.T) {\n\tscenarios := []struct {\n\t\tpath                  string\n\t\tshouldExist           bool\n\t\texpectedContainString string\n\t}{\n\t\t{\n\t\t\tpath:                  \"index.html\",\n\t\t\tshouldExist:           true,\n\t\t\texpectedContainString: \"</body>\",\n\t\t},\n\t\t{\n\t\t\tpath:                  \"favicon.ico\",\n\t\t\tshouldExist:           true,\n\t\t\texpectedContainString: \"\", // not checking because it's an image\n\t\t},\n\t\t{\n\t\t\tpath:                  \"img/logo.svg\",\n\t\t\tshouldExist:           true,\n\t\t\texpectedContainString: \"</svg>\",\n\t\t},\n\t\t{\n\t\t\tpath:                  \"css/app.css\",\n\t\t\tshouldExist:           true,\n\t\t\texpectedContainString: \"background-color\",\n\t\t},\n\t\t{\n\t\t\tpath:                  \"js/app.js\",\n\t\t\tshouldExist:           true,\n\t\t\texpectedContainString: \"function\",\n\t\t},\n\t\t{\n\t\t\tpath:                  \"js/chunk-vendors.js\",\n\t\t\tshouldExist:           true,\n\t\t\texpectedContainString: \"function\",\n\t\t},\n\t\t{\n\t\t\tpath:        \"file-that-does-not-exist.html\",\n\t\t\tshouldExist: false,\n\t\t},\n\t}\n\tstaticFileSystem, err := fs.Sub(FileSystem, RootPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfor _, scenario := range scenarios {\n\t\tt.Run(scenario.path, func(t *testing.T) {\n\t\t\tcontent, err := fs.ReadFile(staticFileSystem, scenario.path)\n\t\t\tif !scenario.shouldExist {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"%s should not have existed\", scenario.path)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"opening %s should not have returned an error, got %s\", scenario.path, err.Error())\n\t\t\t\t}\n\t\t\t\tif len(content) == 0 {\n\t\t\t\t\tt.Errorf(\"%s should have existed in the static FileSystem, but was empty\", scenario.path)\n\t\t\t\t}\n\t\t\t\tif !strings.Contains(string(content), scenario.expectedContainString) {\n\t\t\t\t\tt.Errorf(\"%s should have contained %s, but did not\", scenario.path, scenario.expectedContainString)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  }
]