Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions api/endpoint_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,18 @@ import (
func EndpointStatuses(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
group, status := extractFiltersFromRequest(c)

// Create cache key including filters
cacheKey := fmt.Sprintf("endpoint-status-%d-%d", page, pageSize)
if group != "" {
cacheKey += "-group-" + group
}
if status != "" {
cacheKey += "-status-" + status
}

value, exists := cache.Get(cacheKey)
var data []byte
if !exists {
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
Expand All @@ -36,13 +47,19 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
} else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
}

// Apply filters
if group != "" || status != "" {
endpointStatuses = filterEndpointStatuses(endpointStatuses, group, status)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind explaining what this is? It seems to be unrelated to Redis

}

// Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses)
if err != nil {
logr.Errorf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
cache.SetWithTTL(cacheKey, data, cacheTTL)
} else {
data = value.([]byte)
}
Expand All @@ -51,6 +68,44 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
}
}

// filterEndpointStatuses filters endpoint statuses by group and/or status
func filterEndpointStatuses(endpointStatuses []*endpoint.Status, group, status string) []*endpoint.Status {
if group == "" && status == "" {
return endpointStatuses
}

var filtered []*endpoint.Status
for _, endpointStatus := range endpointStatuses {
// Check group filter
if group != "" && endpointStatus.Group != group {
continue
}

// Check status filter (check the latest result success status)
if status != "" {
if len(endpointStatus.Results) == 0 {
continue
}
latestResult := endpointStatus.Results[0]
switch status {
case "up":
if !latestResult.Success {
continue
}
case "down":
if latestResult.Success {
continue
}
default:
// Unknown status filter, skip this filter
}
}

filtered = append(filtered, endpointStatus)
}
return filtered
}

func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*endpoint.Status, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil
Expand Down
101 changes: 101 additions & 0 deletions api/endpoint_status_filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package api

import (
"testing"

"github.com/TwiN/gatus/v5/config/endpoint"
)

func TestFilterEndpointStatuses(t *testing.T) {
// Create test endpoint statuses
endpointStatuses := []*endpoint.Status{
{
Name: "endpoint1",
Group: "core",
Results: []*endpoint.Result{
{Success: true},
},
},
{
Name: "endpoint2",
Group: "api",
Results: []*endpoint.Result{
{Success: false},
},
},
{
Name: "endpoint3",
Group: "core",
Results: []*endpoint.Result{
{Success: false},
},
},
{
Name: "endpoint4",
Group: "web",
Results: []*endpoint.Result{
{Success: true},
},
},
}

// Test group filter
t.Run("filter by group", func(t *testing.T) {
filtered := filterEndpointStatuses(endpointStatuses, "core", "")
if len(filtered) != 2 {
t.Errorf("Expected 2 endpoints, got %d", len(filtered))
}
for _, ep := range filtered {
if ep.Group != "core" {
t.Errorf("Expected group 'core', got '%s'", ep.Group)
}
}
})

// Test status filter
t.Run("filter by status up", func(t *testing.T) {
filtered := filterEndpointStatuses(endpointStatuses, "", "up")
if len(filtered) != 2 {
t.Errorf("Expected 2 endpoints, got %d", len(filtered))
}
for _, ep := range filtered {
if !ep.Results[0].Success {
t.Errorf("Expected successful endpoint, got failed")
}
}
})

// Test status filter down
t.Run("filter by status down", func(t *testing.T) {
filtered := filterEndpointStatuses(endpointStatuses, "", "down")
if len(filtered) != 2 {
t.Errorf("Expected 2 endpoints, got %d", len(filtered))
}
for _, ep := range filtered {
if ep.Results[0].Success {
t.Errorf("Expected failed endpoint, got successful")
}
}
})

// Test combined filters
t.Run("filter by group and status", func(t *testing.T) {
filtered := filterEndpointStatuses(endpointStatuses, "core", "up")
if len(filtered) != 1 {
t.Errorf("Expected 1 endpoint, got %d", len(filtered))
}
if len(filtered) > 0 {
if filtered[0].Group != "core" || !filtered[0].Results[0].Success {
t.Errorf("Expected core group with success=true")
}
}
})

// Test no filters
t.Run("no filters", func(t *testing.T) {
filtered := filterEndpointStatuses(endpointStatuses, "", "")
if len(filtered) != 4 {
t.Errorf("Expected 4 endpoints, got %d", len(filtered))
}
})
}
7 changes: 7 additions & 0 deletions api/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,10 @@ func extractPageAndPageSizeFromRequest(c *fiber.Ctx, maximumNumberOfResults int)
}
return
}

// extractFiltersFromRequest extracts filter parameters from the request
func extractFiltersFromRequest(c *fiber.Ctx) (group, status string) {
group = c.Query("group")
status = c.Query("status")
return
}
25 changes: 25 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package client

import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
Expand All @@ -20,6 +21,7 @@ import (
"github.com/ishidawataru/sctp"
"github.com/miekg/dns"
ping "github.com/prometheus-community/pro-bing"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/ssh"
"golang.org/x/net/websocket"
)
Expand Down Expand Up @@ -369,6 +371,29 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
return connected, dnsRcode, body, nil
}

// CanConnectToRedis checks if a connection to a Redis server can be established and responds to PING
func CanConnectToRedis(address string, password string, ssl bool, db int, clientConfig *Config) (connected bool, val string, err error) {
ctx, cancel := context.WithTimeout(context.Background(), clientConfig.Timeout)
defer cancel()
opt := &redis.Options{
Addr: address,
Password: password,
DB: db,
TLSConfig: &tls.Config{
InsecureSkipVerify: ssl,
},
}
client := redis.NewClient(opt)
defer client.Close()
status := client.Ping(ctx)
val = status.Val()
err = status.Err()
if status.Err() != nil {
return false, val, err
}
return true, val, err
}

// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
func InjectHTTPClient(httpClient *http.Client) {
injectedHTTPClient = httpClient
Expand Down
36 changes: 36 additions & 0 deletions config/endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
redisconfig "github.com/TwiN/gatus/v5/config/endpoint/redis"
sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/config/maintenance"
Expand Down Expand Up @@ -47,6 +48,7 @@ const (
TypeHTTP Type = "HTTP"
TypeWS Type = "WEBSOCKET"
TypeSSH Type = "SSH"
TypeREDIS Type = "REDIS"
TypeUNKNOWN Type = "UNKNOWN"
)

Expand Down Expand Up @@ -114,6 +116,9 @@ type Endpoint struct {
// SSH is the configuration for SSH monitoring
SSHConfig *sshconfig.Config `yaml:"ssh,omitempty"`

// RedisConfig is the configuration for Redis monitoring
RedisConfig *redisconfig.Config `yaml:"redis,omitempty"`

// ClientConfig is the configuration of the client used to communicate with the endpoint's target
ClientConfig *client.Config `yaml:"client,omitempty"`

Expand Down Expand Up @@ -158,6 +163,8 @@ func (e *Endpoint) Type() Type {
return TypeWS
case strings.HasPrefix(e.URL, "ssh://"):
return TypeSSH
case strings.HasPrefix(e.URL, "redis://"):
return TypeREDIS
default:
return TypeUNKNOWN
}
Expand Down Expand Up @@ -220,6 +227,9 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
if e.SSHConfig != nil {
return e.SSHConfig.Validate()
}
if e.RedisConfig != nil {
return e.RedisConfig.Validate()
}
if e.Type() == TypeUNKNOWN {
return ErrUnknownEndpointType
}
Expand Down Expand Up @@ -407,6 +417,32 @@ func (e *Endpoint) call(result *Result) {
return
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeREDIS {
// Parse Redis URL for address, password, db
u, err := url.Parse(e.URL)
if err != nil {
result.AddError("invalid redis url: " + err.Error())
return
}
address := u.Host
password, _ := e.RedisConfig.Password, 0
if u.User != nil {
password, _ = u.User.Password()
}
ssl := e.RedisConfig.SSL
db := e.RedisConfig.DB
if len(u.Path) > 1 {
fmt.Sscanf(u.Path[1:], "%d", &db)
}
ok, val, err := client.CanConnectToRedis(address, password, ssl, db, e.ClientConfig)
result.Connected = ok
result.Body = []byte(val)
result.HTTPStatus = 200
result.Duration = time.Since(startTime)
if err != nil {
result.AddError(err.Error())
return
}
} else {
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
result.Duration = time.Since(startTime)
Expand Down
25 changes: 25 additions & 0 deletions config/endpoint/redis/redis.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package redis

import (
"errors"
)

var (
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
ErrEndpointWithoutRedisPassword = errors.New("you must specify a password for each REDIS endpoint")
)

type Config struct {
Password string `yaml:"password,omitempty"`
SSL bool `yaml:"ssl,omitempty"`
DB int `yaml:"db,omitempty"`
}

// Validate the SSH configuration
func (cfg *Config) Validate() error {
// If there's no password, return an error
if len(cfg.Password) == 0 {
return ErrEndpointWithoutRedisPassword
}
return nil
}
20 changes: 20 additions & 0 deletions config/endpoint/redis/redis_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package redis

import (
"errors"
"testing"
)

func TestRedis_Validate(t *testing.T) {
cfg := &Config{}
if err := cfg.Validate(); err == nil {
t.Error("expected an error when password is missing")
} else if !errors.Is(err, ErrEndpointWithoutRedisPassword) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutRedisPassword, err)
}

cfg.Password = "password"
if err := cfg.Validate(); err != nil {
t.Errorf("expected no error, got '%v'", err)
}
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
Expand All @@ -63,6 +64,7 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/redis/go-redis/v9 v9.11.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
Expand Down
Loading