Repository: TwiN/gatus
Pull request #279 (2022-04-28 12:48:31)

feat(alerting): Add group-specific WebHook URL for Slack

mani9223-oss: @TwiN Please check. Fixed indent issues

Patch:
diff --git a/.examples/kubernetes/gatus.yaml b/.examples/kubernetes/gatus.yaml
index 2c2550db..f5c22c14 100644
--- a/.examples/kubernetes/gatus.yaml
+++ b/.examples/kubernetes/gatus.yaml
@@ -54,10 +54,10 @@ spec:
       app: gatus
   template:
     metadata:
-      labels:
-        app: gatus
       name: gatus
       namespace: kube-system
+      labels:
+        app: gatus
     spec:
       serviceAccountName: gatus
       terminationGracePeriodSeconds: 5
@@ -76,6 +76,22 @@ spec:
             requests:
               cpu: 50m
               memory: 30M
+          readinessProbe:
+            httpGet:
+              path: /health
+              port: 8080
+            initialDelaySeconds: 5
+            periodSeconds: 10
+            successThreshold: 1
+            failureThreshold: 3
+          livenessProbe:
+            httpGet:
+              path: /health
+              port: 8080
+            initialDelaySeconds: 10
+            periodSeconds: 10
+            successThreshold: 1
+            failureThreshold: 5
           volumeMounts:
             - mountPath: /config
               name: gatus-config
diff --git a/README.md b/README.md
index e49abaa2..e5a9c455 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
 [![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus)
 [![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN)
 
-Gatus is a health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
+Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS
 queries as well as evaluate the result of said queries by using a list of conditions on values like the status code,
 the response time, the certificate expiration, the body and many others. The icing on top is that each of these health
 checks can be paired with alerting via Slack, PagerDuty, Discord, Twilio and more.
@@ -56,6 +56,7 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
   - [Security](#security)
     - [Basic](#basic)
     - [OIDC (ALPHA)](#oidc-alpha)
+  - [Metrics](#metrics)
 - [Deployment](#deployment)
   - [Docker](#docker)
   - [Helm Chart](#helm-chart)
@@ -219,15 +220,15 @@ Here are some examples of conditions you can use:
 
 
 #### Placeholders
-| Placeholder                | Description                                              | Example of resolved value                    |
-|:---------------------------|:---------------------------------------------------------|:---------------------------------------------|
-| `[STATUS]`                 | Resolves into the HTTP status of the request             | 404                                          |
-| `[RESPONSE_TIME]`          | Resolves into the response time the request took, in ms  | 10                                           |
-| `[IP]`                     | Resolves into the IP of the target host                  | 192.168.0.232                                |
-| `[BODY]`                   | Resolves into the response body. Supports JSONPath.      | `{"name":"john.doe"}`                        |
-| `[CONNECTED]`              | Resolves into whether a connection could be established  | `true`                                       |
-| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration | `24h`, `48h`, 0 (if not protocol with certs) |
-| `[DNS_RCODE]`              | Resolves into the DNS status of the response             | NOERROR                                      |
+| Placeholder                | Description                                                                               | Example of resolved value                    |
+|:---------------------------|:------------------------------------------------------------------------------------------|:---------------------------------------------|
+| `[STATUS]`                 | Resolves into the HTTP status of the request                                              | 404                                          |
+| `[RESPONSE_TIME]`          | Resolves into the response time the request took, in ms                                   | 10                                           |
+| `[IP]`                     | Resolves into the IP of the target host                                                   | 192.168.0.232                                |
+| `[BODY]`                   | Resolves into the response body. Supports JSONPath.                                       | `{"name":"john.doe"}`                        |
+| `[CONNECTED]`              | Resolves into whether a connection could be established                                   | `true`                                       |
+| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration (valid units are "s", "m", "h".) | `24h`, `48h`, 0 (if not protocol with certs) |
+| `[DNS_RCODE]`              | Resolves into the DNS status of the response                                              | NOERROR                                      |
 
 
 #### Functions
@@ -442,12 +443,16 @@ endpoints:
 **NOTE:** Some mail servers are painfully slow.
 
 #### Configuring Google Chat alerts
-| Parameter                           | Description                                                                                 | Default       |
-|:------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
-| `alerting.googlechat`               | Configuration for alerts of type `googlechat`                                               | `{}`          |
-| `alerting.googlechat.webhook-url`   | Google Chat Webhook URL                                                                     | Required `""` |
-| `alerting.googlechat.client`        | Client configuration. <br />See [Client configuration](#client-configuration).              | `{}`          |
-| `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A           |
+
+| Parameter                                     | Description                                                                                 | Default       |
+|:--------------------------------------------- |:------------------------------------------------------------------------------------------- |:------------- |
+| `alerting.googlechat`                         | Configuration for alerts of type `googlechat`                                               | `{}`          |
+| `alerting.googlechat.webhook-url`             | Google Chat Webhook URL                                                                     | Required `""` |
+| `alerting.googlechat.client`                  | Client configuration. <br />See [Client configuration](#client-configuration).              | `{}`          |
+| `alerting.googlechat.default-alert`           | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A           |
+| `alerting.googlechat.overrides`               | List of overrides that may be prioritized over the default configuration                    | `[]`          |
+| `alerting.googlechat.overrides[].group`       | Endpoint group for which the configuration will be overridden by this configuration         | `""`          |
+| `alerting.googlechat.overrides[].webhook-url` | Teams Webhook URL                                                                           | `""`          |
 
 ```yaml
 alerting:
@@ -621,12 +626,14 @@ endpoints:
 
 
 #### Configuring Slack alerts
-| Parameter                      | Description                                                                                | Default       |
-|:-------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
-| `alerting.slack`               | Configuration for alerts of type `slack`                                                   | `{}`          |
-| `alerting.slack.webhook-url`   | Slack Webhook URL                                                                          | Required `""` |
-| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |
-
+| Parameter                                 | Description                                                                                | Default       |
+|:------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
+| `alerting.slack`                          | Configuration for alerts of type `slack`                                                   | `{}`          |
+| `alerting.slack.webhook-url`              | Slack Webhook URL                                                                          | Required `""` |
+| `alerting.slack.default-alert`            | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |
+| `alerting.slack.overrides`                | List of overrides that may be prioritized over the default configuration                   | `[]`          |
+| `alerting.slack.overrides[].group`        | Endpoint group for which the configuration will be overridden by this configuration        | `""`          |
+| `alerting.slack.overrides[].webhook-url`  | Slack Webhook URL                                                                          | `""`          |
 ```yaml
 alerting:
   slack: 
@@ -1017,6 +1024,19 @@ security:
 **NOTE:** The OIDC feature is currently in Alpha. Breaking changes may occur. Use this feature at your own risk.
 
 
+### Metrics
+To enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics`
+endpoint on the same port your application is configured to run on (`web.port`).
+
+| Metric name                                  | Type    | Description                                                                | Labels                          | Relevant endpoint types |
+|:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------|
+| gatus_results_total                          | counter | Number of results per endpoint                                             | key, group, name, type, success | All                     |
+| gatus_results_code_total                     | counter | Total number of results by code                                            | key, group, name, type, code    | DNS, HTTP               |
+| gatus_results_connected_total                | counter | Total number of results in which a connection was successfully established | key, group, name, type          | All                     |
+| gatus_results_duration_seconds               | gauge   | Duration of the request in seconds                                         | key, group, name, type          | All                     |
+| gatus_results_certificate_expiration_seconds | gauge   | Number of seconds until the certificate expires                            | key, group, name, type          | HTTP, STARTTLS          |
+
+
 ## Deployment
 Many examples can be found in the [.examples](.examples) folder, but this section will focus on the most popular ways of deploying Gatus.
 
diff --git a/alerting/provider/googlechat/googlechat.go b/alerting/provider/googlechat/googlechat.go
index d6162ae4..405f41f4 100644
--- a/alerting/provider/googlechat/googlechat.go
+++ b/alerting/provider/googlechat/googlechat.go
@@ -20,6 +20,15 @@ type AlertProvider struct {
 
 	// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
 	DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+	// Overrides is a list of Override that may be prioritized over the default configuration
+	Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+	Group      string `yaml:"group"`
+	WebhookURL string `yaml:"webhook-url"`
 }
 
 // IsValid returns whether the provider's configuration is valid
@@ -27,13 +36,24 @@ func (provider *AlertProvider) IsValid() bool {
 	if provider.ClientConfig == nil {
 		provider.ClientConfig = client.GetDefaultConfig()
 	}
+
+	registeredGroups := make(map[string]bool)
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
+				return false
+			}
+			registeredGroups[override.Group] = true
+		}
+	}
+
 	return len(provider.WebhookURL) > 0
 }
 
 // Send an alert using the provider
 func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
 	buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
-	request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
+	request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
 	if err != nil {
 		return err
 	}
@@ -118,6 +138,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
 }`, endpoint.Name, endpoint.Group, message, description, results, endpoint.URL)
 }
 
+// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
+func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if group == override.Group {
+				return override.WebhookURL
+			}
+		}
+	}
+	return provider.WebhookURL
+}
+
 // GetDefaultAlert returns the provider's default alert configuration
 func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
 	return provider.DefaultAlert
diff --git a/alerting/provider/slack/slack.go b/alerting/provider/slack/slack.go
index 2de9e5cb..fbde77f2 100644
--- a/alerting/provider/slack/slack.go
+++ b/alerting/provider/slack/slack.go
@@ -14,20 +14,36 @@ import (
 // AlertProvider is the configuration necessary for sending an alert using Slack
 type AlertProvider struct {
 	WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
-
 	// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
 	DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+	// Overrides is a list of Override that may be prioritized over the default configuration
+	Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+	Group      string `yaml:"group"`
+	WebhookURL string `yaml:"webhook-url"`
 }
 
 // IsValid returns whether the provider's configuration is valid
 func (provider *AlertProvider) IsValid() bool {
+	registeredGroups := make(map[string]bool)
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
+				return false
+			}
+			registeredGroups[override.Group] = true
+		}
+	}
 	return len(provider.WebhookURL) > 0
 }
 
 // Send an alert using the provider
 func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
 	buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
-	request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
+	request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
 	if err != nil {
 		return err
 	}
@@ -86,6 +102,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
 }`, message, description, color, results)
 }
 
+// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
+func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if group == override.Group {
+				return override.WebhookURL
+			}
+		}
+	}
+	return provider.WebhookURL
+}
+
 // GetDefaultAlert returns the provider's default alert configuration
 func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
 	return provider.DefaultAlert
diff --git a/core/endpoint.go b/core/endpoint.go
index acaed8ec..4006e7ea 100644
--- a/core/endpoint.go
+++ b/core/endpoint.go
@@ -18,6 +18,8 @@ import (
 	"github.com/TwiN/gatus/v3/util"
 )
 
+type EndpointType string
+
 const (
 	// HostHeader is the name of the header used to specify the host
 	HostHeader = "Host"
@@ -30,6 +32,14 @@ const (
 
 	// GatusUserAgent is the default user agent that Gatus uses to send requests.
 	GatusUserAgent = "Gatus/1.0"
+
+	// EndpointType enum for the endpoint type.
+	EndpointTypeDNS      EndpointType = "DNS"
+	EndpointTypeTCP      EndpointType = "TCP"
+	EndpointTypeICMP     EndpointType = "ICMP"
+	EndpointTypeSTARTTLS EndpointType = "STARTTLS"
+	EndpointTypeTLS      EndpointType = "TLS"
+	EndpointTypeHTTP     EndpointType = "HTTP"
 )
 
 var (
@@ -105,6 +115,24 @@ func (endpoint Endpoint) IsEnabled() bool {
 	return *endpoint.Enabled
 }
 
+// Type returns the endpoint type
+func (endpoint Endpoint) Type() EndpointType {
+	switch {
+	case endpoint.DNS != nil:
+		return EndpointTypeDNS
+	case strings.HasPrefix(endpoint.URL, "tcp://"):
+		return EndpointTypeTCP
+	case strings.HasPrefix(endpoint.URL, "icmp://"):
+		return EndpointTypeICMP
+	case strings.HasPrefix(endpoint.URL, "starttls://"):
+		return EndpointTypeSTARTTLS
+	case strings.HasPrefix(endpoint.URL, "tls://"):
+		return EndpointTypeTLS
+	default:
+		return EndpointTypeHTTP
+	}
+}
+
 // ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of fields that have one
 func (endpoint *Endpoint) ValidateAndSetDefaults() error {
 	// Set default values
@@ -229,21 +257,16 @@ func (endpoint *Endpoint) call(result *Result) {
 	var response *http.Response
 	var err error
 	var certificate *x509.Certificate
-	isTypeDNS := endpoint.DNS != nil
-	isTypeTCP := strings.HasPrefix(endpoint.URL, "tcp://")
-	isTypeICMP := strings.HasPrefix(endpoint.URL, "icmp://")
-	isTypeSTARTTLS := strings.HasPrefix(endpoint.URL, "starttls://")
-	isTypeTLS := strings.HasPrefix(endpoint.URL, "tls://")
-	isTypeHTTP := !isTypeDNS && !isTypeTCP && !isTypeICMP && !isTypeSTARTTLS && !isTypeTLS
-	if isTypeHTTP {
+	endpointType := endpoint.Type()
+	if endpointType == EndpointTypeHTTP {
 		request = endpoint.buildHTTPRequest()
 	}
 	startTime := time.Now()
-	if isTypeDNS {
+	if endpointType == EndpointTypeDNS {
 		endpoint.DNS.query(endpoint.URL, result)
 		result.Duration = time.Since(startTime)
-	} else if isTypeSTARTTLS || isTypeTLS {
-		if isTypeSTARTTLS {
+	} else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS {
+		if endpointType == EndpointTypeSTARTTLS {
 			result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig)
 		} else {
 			result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig)
@@ -254,10 +277,10 @@ func (endpoint *Endpoint) call(result *Result) {
 		}
 		result.Duration = time.Since(startTime)
 		result.CertificateExpiration = time.Until(certificate.NotAfter)
-	} else if isTypeTCP {
+	} else if endpointType == EndpointTypeTCP {
 		result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig)
 		result.Duration = time.Since(startTime)
-	} else if isTypeICMP {
+	} else if endpointType == EndpointTypeICMP {
 		result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig)
 	} else {
 		response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request)
diff --git a/metric/metric.go b/metric/metric.go
index a3e8dfcd..89edbe1a 100644
--- a/metric/metric.go
+++ b/metric/metric.go
@@ -8,20 +8,66 @@ import (
 	"github.com/prometheus/client_golang/prometheus/promauto"
 )
 
+const namespace = "gatus" // The prefix of the metrics
+
 var (
-	// This will be initialized once PublishMetricsForEndpoint.
-	// The reason why we're doing this is that if metrics are disabled, we don't want to initialize it unnecessarily.
-	resultCount *prometheus.CounterVec = nil
+	initializedMetrics bool // Whether the metrics have been initialized
+
+	resultTotal                        *prometheus.CounterVec
+	resultDurationSeconds              *prometheus.GaugeVec
+	resultConnectedTotal               *prometheus.CounterVec
+	resultCodeTotal                    *prometheus.CounterVec
+	resultCertificateExpirationSeconds *prometheus.GaugeVec
 )
 
+func initializePrometheusMetrics() {
+	resultTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: namespace,
+		Name:      "results_total",
+		Help:      "Number of results per endpoint",
+	}, []string{"key", "group", "name", "type", "success"})
+	resultDurationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
+		Namespace: namespace,
+		Name:      "results_duration_seconds",
+		Help:      "Duration of the request in seconds",
+	}, []string{"key", "group", "name", "type"})
+	resultConnectedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: namespace,
+		Name:      "results_connected_total",
+		Help:      "Total number of results in which a connection was successfully established",
+	}, []string{"key", "group", "name", "type"})
+	resultCodeTotal = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: namespace,
+		Name:      "results_code_total",
+		Help:      "Total number of results by code",
+	}, []string{"key", "group", "name", "type", "code"})
+	resultCertificateExpirationSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
+		Namespace: namespace,
+		Name:      "results_certificate_expiration_seconds",
+		Help:      "Number of seconds until the certificate expires",
+	}, []string{"key", "group", "name", "type"})
+}
+
 // PublishMetricsForEndpoint publishes metrics for the given endpoint and its result.
 // These metrics will be exposed at /metrics if the metrics are enabled
 func PublishMetricsForEndpoint(endpoint *core.Endpoint, result *core.Result) {
-	if resultCount == nil {
-		resultCount = promauto.NewCounterVec(prometheus.CounterOpts{
-			Name: "gatus_results_total",
-			Help: "Number of results per endpoint",
-		}, []string{"key", "group", "name", "success"})
+	if !initializedMetrics {
+		initializePrometheusMetrics()
+		initializedMetrics = true
+	}
+	endpointType := endpoint.Type()
+	resultTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), strconv.FormatBool(result.Success)).Inc()
+	resultDurationSeconds.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Set(result.Duration.Seconds())
+	if result.Connected {
+		resultConnectedTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Inc()
+	}
+	if result.DNSRCode != "" {
+		resultCodeTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), result.DNSRCode).Inc()
+	}
+	if result.HTTPStatus != 0 {
+		resultCodeTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)).Inc()
+	}
+	if result.CertificateExpiration != 0 {
+		resultCertificateExpirationSeconds.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Set(result.CertificateExpiration.Seconds())
 	}
-	resultCount.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, strconv.FormatBool(result.Success)).Inc()
 }


Test patch:
diff --git a/alerting/provider/googlechat/googlechat_test.go b/alerting/provider/googlechat/googlechat_test.go
index 55126911..543cee61 100644
--- a/alerting/provider/googlechat/googlechat_test.go
+++ b/alerting/provider/googlechat/googlechat_test.go
@@ -11,7 +11,7 @@ import (
 	"github.com/TwiN/gatus/v3/test"
 )
 
-func TestAlertProvider_IsValid(t *testing.T) {
+func TestAlertDefaultProvider_IsValid(t *testing.T) {
 	invalidProvider := AlertProvider{WebhookURL: ""}
 	if invalidProvider.IsValid() {
 		t.Error("provider shouldn't have been valid")
@@ -22,6 +22,43 @@ func TestAlertProvider_IsValid(t *testing.T) {
 	}
 }
 
+func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+	providerWithInvalidOverrideGroup := AlertProvider{
+		Overrides: []Override{
+			{
+				WebhookURL: "http://example.com",
+				Group:      "",
+			},
+		},
+	}
+	if providerWithInvalidOverrideGroup.IsValid() {
+		t.Error("provider Group shouldn't have been valid")
+	}
+	providerWithInvalidOverrideTo := AlertProvider{
+		Overrides: []Override{
+			{
+				WebhookURL: "",
+				Group:      "group",
+			},
+		},
+	}
+	if providerWithInvalidOverrideTo.IsValid() {
+		t.Error("provider integration key shouldn't have been valid")
+	}
+	providerWithValidOverride := AlertProvider{
+		WebhookURL: "http://example.com",
+		Overrides: []Override{
+			{
+				WebhookURL: "http://example.com",
+				Group:      "group",
+			},
+		},
+	}
+	if !providerWithValidOverride.IsValid() {
+		t.Error("provider should've been valid")
+	}
+}
+
 func TestAlertProvider_Send(t *testing.T) {
 	defer client.InjectHTTPClient(nil)
 	firstDescription := "description-1"
@@ -158,3 +195,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
 		t.Error("expected default alert to be nil")
 	}
 }
+
+func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
+	tests := []struct {
+		Name           string
+		Provider       AlertProvider
+		InputGroup     string
+		ExpectedOutput string
+	}{
+		{
+			Name: "provider-no-override-specify-no-group-should-default",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides:  nil,
+			},
+			InputGroup:     "",
+			ExpectedOutput: "http://example.com",
+		},
+		{
+			Name: "provider-no-override-specify-group-should-default",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides:  nil,
+			},
+			InputGroup:     "group",
+			ExpectedOutput: "http://example.com",
+		},
+		{
+			Name: "provider-with-override-specify-no-group-should-default",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides: []Override{
+					{
+						Group:      "group",
+						WebhookURL: "http://example01.com",
+					},
+				},
+			},
+			InputGroup:     "",
+			ExpectedOutput: "http://example.com",
+		},
+		{
+			Name: "provider-with-override-specify-group-should-override",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides: []Override{
+					{
+						Group:      "group",
+						WebhookURL: "http://example01.com",
+					},
+				},
+			},
+			InputGroup:     "group",
+			ExpectedOutput: "http://example01.com",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
+				t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
+			}
+		})
+	}
+}
diff --git a/alerting/provider/slack/slack_test.go b/alerting/provider/slack/slack_test.go
index 1f60f547..909aafff 100644
--- a/alerting/provider/slack/slack_test.go
+++ b/alerting/provider/slack/slack_test.go
@@ -11,7 +11,7 @@ import (
 	"github.com/TwiN/gatus/v3/test"
 )
 
-func TestAlertProvider_IsValid(t *testing.T) {
+func TestAlertDefaultProvider_IsValid(t *testing.T) {
 	invalidProvider := AlertProvider{WebhookURL: ""}
 	if invalidProvider.IsValid() {
 		t.Error("provider shouldn't have been valid")
@@ -20,8 +20,44 @@ func TestAlertProvider_IsValid(t *testing.T) {
 	if !validProvider.IsValid() {
 		t.Error("provider should've been valid")
 	}
-}
 
+}
+func TestAlertProvider_IsValidWithOverride(t *testing.T) {
+	providerWithInvalidOverrideGroup := AlertProvider{
+		Overrides: []Override{
+			{
+				WebhookURL: "http://example.com",
+				Group:      "",
+			},
+		},
+	}
+	if providerWithInvalidOverrideGroup.IsValid() {
+		t.Error("provider Group shouldn't have been valid")
+	}
+	providerWithInvalidOverrideTo := AlertProvider{
+		Overrides: []Override{
+			{
+				WebhookURL: "",
+				Group:      "group",
+			},
+		},
+	}
+	if providerWithInvalidOverrideTo.IsValid() {
+		t.Error("provider integration key shouldn't have been valid")
+	}
+	providerWithValidOverride := AlertProvider{
+		WebhookURL: "http://example.com",
+		Overrides: []Override{
+			{
+				WebhookURL: "http://example.com",
+				Group:      "group",
+			},
+		},
+	}
+	if !providerWithValidOverride.IsValid() {
+		t.Error("provider should've been valid")
+	}
+}
 func TestAlertProvider_Send(t *testing.T) {
 	defer client.InjectHTTPClient(nil)
 	firstDescription := "description-1"
@@ -79,7 +115,7 @@ func TestAlertProvider_Send(t *testing.T) {
 		t.Run(scenario.Name, func(t *testing.T) {
 			client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
 			err := scenario.Provider.Send(
-				&core.Endpoint{Name: "name"},
+				&core.Endpoint{Name: "endpoint-name"},
 				&scenario.Alert,
 				&core.Result{
 					ConditionResults: []*core.ConditionResult{
@@ -175,3 +211,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
 		t.Error("expected default alert to be nil")
 	}
 }
+
+func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
+	tests := []struct {
+		Name           string
+		Provider       AlertProvider
+		InputGroup     string
+		ExpectedOutput string
+	}{
+		{
+			Name: "provider-no-override-specify-no-group-should-default",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides:  nil,
+			},
+			InputGroup:     "",
+			ExpectedOutput: "http://example.com",
+		},
+		{
+			Name: "provider-no-override-specify-group-should-default",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides:  nil,
+			},
+			InputGroup:     "group",
+			ExpectedOutput: "http://example.com",
+		},
+		{
+			Name: "provider-with-override-specify-no-group-should-default",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides: []Override{
+					{
+						Group:      "group",
+						WebhookURL: "http://example01.com",
+					},
+				},
+			},
+			InputGroup:     "",
+			ExpectedOutput: "http://example.com",
+		},
+		{
+			Name: "provider-with-override-specify-group-should-override",
+			Provider: AlertProvider{
+				WebhookURL: "http://example.com",
+				Overrides: []Override{
+					{
+						Group:      "group",
+						WebhookURL: "http://example01.com",
+					},
+				},
+			},
+			InputGroup:     "group",
+			ExpectedOutput: "http://example01.com",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
+				t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
+			}
+		})
+	}
+}
diff --git a/client/client_test.go b/client/client_test.go
index 7e8dc311..c713a399 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -2,7 +2,7 @@ package client
 
 import (
 	"bytes"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"testing"
 	"time"
@@ -180,7 +180,7 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
 			if r.Host == "token-server.local" {
 				return &http.Response{
 					StatusCode: http.StatusOK,
-					Body: ioutil.NopCloser(bytes.NewReader(
+					Body: io.NopCloser(bytes.NewReader(
 						[]byte(
 							`{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`,
 						),
diff --git a/config/maintenance/maintenance_test.go b/config/maintenance/maintenance_test.go
index 747b0010..7f97734e 100644
--- a/config/maintenance/maintenance_test.go
+++ b/config/maintenance/maintenance_test.go
@@ -98,6 +98,15 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
 			},
 			expectedError: nil,
 		},
+		{
+			name: "every-day-explicitly-at-2300",
+			cfg: &Config{
+				Start:    "23:00",
+				Duration: time.Hour,
+				Every:    []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
+			},
+			expectedError: nil,
+		},
 		{
 			name: "every-monday-at-0000",
 			cfg: &Config{
@@ -168,6 +177,24 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
 			},
 			expected: true,
 		},
+		{
+			name: "under-maintenance-starting-now-for-8h-explicit-days",
+			cfg: &Config{
+				Start:    fmt.Sprintf("%02d:00", now.Hour()),
+				Duration: 8 * time.Hour,
+				Every:    []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
+			},
+			expected: true,
+		},
+		{
+			name: "under-maintenance-starting-now-for-23h-explicit-days",
+			cfg: &Config{
+				Start:    fmt.Sprintf("%02d:00", now.Hour()),
+				Duration: 23 * time.Hour,
+				Every:    []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
+			},
+			expected: true,
+		},
 		{
 			name: "under-maintenance-starting-4h-ago-for-8h",
 			cfg: &Config{
@@ -176,6 +203,14 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
 			},
 			expected: true,
 		},
+		{
+			name: "under-maintenance-starting-22h-ago-for-23h",
+			cfg: &Config{
+				Start:    fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
+				Duration: 23 * time.Hour,
+			},
+			expected: true,
+		},
 		{
 			name: "under-maintenance-starting-4h-ago-for-3h",
 			cfg: &Config{
diff --git a/core/endpoint_test.go b/core/endpoint_test.go
index ddf5c552..03b00b1f 100644
--- a/core/endpoint_test.go
+++ b/core/endpoint_test.go
@@ -23,6 +23,62 @@ func TestEndpoint_IsEnabled(t *testing.T) {
 	}
 }
 
+func TestEndpoint_Type(t *testing.T) {
+	type fields struct {
+		URL string
+		DNS *DNS
+	}
+	tests := []struct {
+		fields fields
+		want   EndpointType
+	}{{
+		fields: fields{
+			URL: "8.8.8.8",
+			DNS: &DNS{
+				QueryType: "A",
+				QueryName: "example.com",
+			},
+		},
+		want: EndpointTypeDNS,
+	}, {
+		fields: fields{
+			URL: "tcp://127.0.0.1:6379",
+		},
+		want: EndpointTypeTCP,
+	}, {
+		fields: fields{
+			URL: "icmp://example.com",
+		},
+		want: EndpointTypeICMP,
+	}, {
+		fields: fields{
+			URL: "starttls://smtp.gmail.com:587",
+		},
+		want: EndpointTypeSTARTTLS,
+	}, {
+		fields: fields{
+			URL: "tls://example.com:443",
+		},
+		want: EndpointTypeTLS,
+	}, {
+		fields: fields{
+			URL: "https://twin.sh/health",
+		},
+		want: EndpointTypeHTTP,
+	}}
+	for _, tt := range tests {
+		t.Run(string(tt.want), func(t *testing.T) {
+			endpoint := Endpoint{
+				URL: tt.fields.URL,
+				DNS: tt.fields.DNS,
+			}
+			if got := endpoint.Type(); got != tt.want {
+				t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
 func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
 	condition := Condition("[STATUS] == 200")
 	endpoint := Endpoint{