diff --git a/controller/badge_test.go b/controller/badge_test.go deleted file mode 100644 index 80ce9d92..00000000 --- a/controller/badge_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package controller - -import ( - "strconv" - "testing" -) - -func TestGetBadgeColorFromUptime(t *testing.T) { - scenarios := []struct { - Uptime float64 - ExpectedColor string - }{ - { - Uptime: 1, - ExpectedColor: badgeColorHexAwesome, - }, - { - Uptime: 0.99, - ExpectedColor: badgeColorHexAwesome, - }, - { - Uptime: 0.97, - ExpectedColor: badgeColorHexGreat, - }, - { - Uptime: 0.95, - ExpectedColor: badgeColorHexGreat, - }, - { - Uptime: 0.93, - ExpectedColor: badgeColorHexGood, - }, - { - Uptime: 0.9, - ExpectedColor: badgeColorHexGood, - }, - { - Uptime: 0.85, - ExpectedColor: badgeColorHexPassable, - }, - { - Uptime: 0.7, - ExpectedColor: badgeColorHexBad, - }, - { - Uptime: 0.65, - ExpectedColor: badgeColorHexBad, - }, - { - Uptime: 0.6, - ExpectedColor: badgeColorHexVeryBad, - }, - } - for _, scenario := range scenarios { - t.Run("uptime-"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) { - if getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor { - t.Errorf("expected %s from %f, got %v", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime)) - } - }) - } -} - -func TestGetBadgeColorFromResponseTime(t *testing.T) { - scenarios := []struct { - ResponseTime int - ExpectedColor string - }{ - { - ResponseTime: 10, - ExpectedColor: badgeColorHexAwesome, - }, - { - ResponseTime: 50, - ExpectedColor: badgeColorHexAwesome, - }, - { - ResponseTime: 75, - ExpectedColor: badgeColorHexGreat, - }, - { - ResponseTime: 150, - ExpectedColor: badgeColorHexGreat, - }, - { - ResponseTime: 201, - ExpectedColor: badgeColorHexGood, - }, - { - ResponseTime: 300, - ExpectedColor: badgeColorHexGood, - }, - { - ResponseTime: 301, - ExpectedColor: badgeColorHexPassable, - }, - { - ResponseTime: 450, - ExpectedColor: badgeColorHexPassable, - }, - { - ResponseTime: 700, - ExpectedColor: badgeColorHexBad, - }, - { - ResponseTime: 1500, - ExpectedColor: badgeColorHexVeryBad, - }, - } - for _, scenario := range scenarios { - t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) { - if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor { - t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime)) - } - }) - } -} diff --git a/controller/controller.go b/controller/controller.go index 2b8eec8a..9355fc0f 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -1,35 +1,19 @@ package controller import ( - "bytes" - "compress/gzip" "context" - "encoding/json" "fmt" "log" "net/http" "os" - "strings" "time" "github.com/TwinProduction/gatus/config" + "github.com/TwinProduction/gatus/controller/handler" "github.com/TwinProduction/gatus/security" - "github.com/TwinProduction/gatus/storage" - "github.com/TwinProduction/gatus/storage/store/common" - "github.com/TwinProduction/gatus/storage/store/common/paging" - "github.com/TwinProduction/gocache" - "github.com/TwinProduction/health" - "github.com/gorilla/mux" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -const ( - cacheTTL = 10 * time.Second ) var ( - cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut) - // server is the http.Server created by Handle. // The only reason it exists is for testing purposes. server *http.Server @@ -37,9 +21,9 @@ var ( // Handle creates the router and starts the server func Handle(securityConfig *security.Config, webConfig *config.WebConfig, uiConfig *config.UIConfig, enableMetrics bool) { - var router http.Handler = CreateRouter(config.StaticFolder, securityConfig, uiConfig, enableMetrics) + var router http.Handler = handler.CreateRouter(config.StaticFolder, securityConfig, uiConfig, enableMetrics) if os.Getenv("ENVIRONMENT") == "dev" { - router = developmentCorsHandler(router) + router = handler.DevelopmentCORS(router) } server = &http.Server{ Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port), @@ -62,111 +46,3 @@ func Shutdown() { server = nil } } - -// CreateRouter creates the router for the http server -func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *config.UIConfig, enabledMetrics bool) *mux.Router { - router := mux.NewRouter() - if enabledMetrics { - router.Handle("/metrics", promhttp.Handler()).Methods("GET") - } - router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") - router.HandleFunc("/favicon.ico", favIconHandler(staticFolder)).Methods("GET") - // Endpoints - router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already - router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceStatusHandler))).Methods("GET") - // TODO: router.HandleFunc("/api/v1/services/{key}/uptimes", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceUptimesHandler))).Methods("GET") - // TODO: router.HandleFunc("/api/v1/services/{key}/events", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceEventsHandler))).Methods("GET") - router.HandleFunc("/api/v1/services/{key}/uptimes/{duration}/badge.svg", uptimeBadgeHandler).Methods("GET") - router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", responseTimeBadgeHandler).Methods("GET") - router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", responseTimeChartHandler).Methods("GET") - // SPA - router.HandleFunc("/services/{service}", spaHandler(staticFolder, uiConfig)).Methods("GET") - router.HandleFunc("/", spaHandler(staticFolder, uiConfig)).Methods("GET") - // Everything else falls back on static content - router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder)))) - return router -} - -func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc { - if securityConfig != nil && securityConfig.IsValid() { - return security.Handler(handler, securityConfig) - } - return handler -} - -// serviceStatusesHandler handles requests to retrieve all service statuses -// Due to the size of the response, this function leverages a cache. -// Must not be wrapped by GzipHandler -func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) { - page, pageSize := extractPageAndPageSizeFromRequest(r) - gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") - var exists bool - var value interface{} - if gzipped { - writer.Header().Set("Content-Encoding", "gzip") - value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize)) - } else { - value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d", page, pageSize)) - } - var data []byte - if !exists { - var err error - buffer := &bytes.Buffer{} - gzipWriter := gzip.NewWriter(buffer) - serviceStatuses, err := storage.Get().GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(page, pageSize)) - if err != nil { - log.Printf("[controller][serviceStatusesHandler] Failed to retrieve service statuses: %s", err.Error()) - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - data, err = json.Marshal(serviceStatuses) - if err != nil { - log.Printf("[controller][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error()) - http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError) - return - } - _, _ = gzipWriter.Write(data) - _ = gzipWriter.Close() - gzippedData := buffer.Bytes() - cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d", page, pageSize), data, cacheTTL) - cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL) - if gzipped { - data = gzippedData - } - } else { - data = value.([]byte) - } - writer.Header().Add("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(data) -} - -// serviceStatusHandler retrieves a single ServiceStatus by group name and service name -func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) { - page, pageSize := extractPageAndPageSizeFromRequest(r) - vars := mux.Vars(r) - serviceStatus, err := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents)) - if err != nil { - if err == common.ErrServiceNotFound { - http.Error(writer, err.Error(), http.StatusNotFound) - return - } - log.Printf("[controller][serviceStatusHandler] Failed to retrieve service status: %s", err.Error()) - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - if serviceStatus == nil { - log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"]) - http.Error(writer, "not found", http.StatusNotFound) - return - } - output, err := json.Marshal(serviceStatus) - if err != nil { - log.Printf("[controller][serviceStatusHandler] Unable to marshal object to JSON: %s", err.Error()) - http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError) - return - } - writer.Header().Add("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(output) -} diff --git a/controller/controller_test.go b/controller/controller_test.go index f5da26e9..ea66bdac 100644 --- a/controller/controller_test.go +++ b/controller/controller_test.go @@ -6,267 +6,12 @@ import ( "net/http/httptest" "os" "testing" - "time" "github.com/TwinProduction/gatus/config" "github.com/TwinProduction/gatus/core" - "github.com/TwinProduction/gatus/storage" - "github.com/TwinProduction/gatus/watchdog" ) -var ( - firstCondition = core.Condition("[STATUS] == 200") - secondCondition = core.Condition("[RESPONSE_TIME] < 500") - thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") - - timestamp = time.Now() - - testService = core.Service{ - Name: "name", - Group: "group", - URL: "https://example.org/what/ever", - Method: "GET", - Body: "body", - Interval: 30 * time.Second, - Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition}, - Alerts: nil, - NumberOfFailuresInARow: 0, - NumberOfSuccessesInARow: 0, - } - testSuccessfulResult = core.Result{ - Hostname: "example.org", - IP: "127.0.0.1", - HTTPStatus: 200, - Errors: nil, - Connected: true, - Success: true, - Timestamp: timestamp, - Duration: 150 * time.Millisecond, - CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ - { - Condition: "[STATUS] == 200", - Success: true, - }, - { - Condition: "[RESPONSE_TIME] < 500", - Success: true, - }, - { - Condition: "[CERTIFICATE_EXPIRATION] < 72h", - Success: true, - }, - }, - } - testUnsuccessfulResult = core.Result{ - Hostname: "example.org", - IP: "127.0.0.1", - HTTPStatus: 200, - Errors: []string{"error-1", "error-2"}, - Connected: true, - Success: false, - Timestamp: timestamp, - Duration: 750 * time.Millisecond, - CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ - { - Condition: "[STATUS] == 200", - Success: true, - }, - { - Condition: "[RESPONSE_TIME] < 500", - Success: false, - }, - { - Condition: "[CERTIFICATE_EXPIRATION] < 72h", - Success: false, - }, - }, - } -) - -func TestCreateRouter(t *testing.T) { - defer storage.Get().Clear() - defer cache.Clear() - cfg := &config.Config{ - Metrics: true, - Services: []*core.Service{ - { - Name: "frontend", - Group: "core", - }, - { - Name: "backend", - Group: "core", - }, - }, - } - watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) - watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) - router := CreateRouter("../web/static", cfg.Security, nil, cfg.Metrics) - type Scenario struct { - Name string - Path string - ExpectedCode int - Gzip bool - } - scenarios := []Scenario{ - { - Name: "health", - Path: "/health", - ExpectedCode: http.StatusOK, - }, - { - Name: "metrics", - Path: "/metrics", - ExpectedCode: http.StatusOK, - }, - { - Name: "badge-uptime-1h", - Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg", - ExpectedCode: http.StatusOK, - }, - { - Name: "badge-uptime-24h", - Path: "/api/v1/services/core_backend/uptimes/24h/badge.svg", - ExpectedCode: http.StatusOK, - }, - { - Name: "badge-uptime-7d", - Path: "/api/v1/services/core_frontend/uptimes/7d/badge.svg", - ExpectedCode: http.StatusOK, - }, - { - Name: "badge-uptime-with-invalid-duration", - Path: "/api/v1/services/core_backend/uptimes/3d/badge.svg", - ExpectedCode: http.StatusBadRequest, - }, - { - Name: "badge-uptime-for-invalid-key", - Path: "/api/v1/services/invalid_key/uptimes/7d/badge.svg", - ExpectedCode: http.StatusNotFound, - }, - { - Name: "badge-response-time-1h", - Path: "/api/v1/services/core_frontend/response-times/1h/badge.svg", - ExpectedCode: http.StatusOK, - }, - { - Name: "badge-response-time-24h", - Path: "/api/v1/services/core_backend/response-times/24h/badge.svg", - ExpectedCode: http.StatusOK, - }, - { - Name: "badge-response-time-7d", - Path: "/api/v1/services/core_frontend/response-times/7d/badge.svg", - ExpectedCode: http.StatusOK, - }, - { - Name: "badge-response-time-with-invalid-duration", - Path: "/api/v1/services/core_backend/response-times/3d/badge.svg", - ExpectedCode: http.StatusBadRequest, - }, - { - Name: "badge-response-time-for-invalid-key", - Path: "/api/v1/services/invalid_key/response-times/7d/badge.svg", - ExpectedCode: http.StatusNotFound, - }, - { - Name: "chart-response-time-24h", - Path: "/api/v1/services/core_backend/response-times/24h/chart.svg", - ExpectedCode: http.StatusOK, - }, - { - Name: "chart-response-time-7d", - Path: "/api/v1/services/core_frontend/response-times/7d/chart.svg", - ExpectedCode: http.StatusOK, - }, - { - Name: "chart-response-time-with-invalid-duration", - Path: "/api/v1/services/core_backend/response-times/3d/chart.svg", - ExpectedCode: http.StatusBadRequest, - }, - { - Name: "chart-response-time-for-invalid-key", - Path: "/api/v1/services/invalid_key/response-times/7d/chart.svg", - ExpectedCode: http.StatusNotFound, - }, - { - Name: "service-statuses", - Path: "/api/v1/services/statuses", - ExpectedCode: http.StatusOK, - }, - { - Name: "service-statuses-gzip", - Path: "/api/v1/services/statuses", - ExpectedCode: http.StatusOK, - Gzip: true, - }, - { - Name: "service-statuses-pagination", - Path: "/api/v1/services/statuses?page=1&pageSize=20", - ExpectedCode: http.StatusOK, - }, - { - Name: "service-status", - Path: "/api/v1/services/core_frontend/statuses", - ExpectedCode: http.StatusOK, - }, - { - Name: "service-status-gzip", - Path: "/api/v1/services/core_frontend/statuses", - ExpectedCode: http.StatusOK, - Gzip: true, - }, - { - Name: "service-status-pagination", - Path: "/api/v1/services/core_frontend/statuses?page=1&pageSize=20", - ExpectedCode: http.StatusOK, - }, - { - Name: "service-status-for-invalid-key", - Path: "/api/v1/services/invalid_key/statuses", - ExpectedCode: http.StatusNotFound, - }, - { - Name: "favicon", - Path: "/favicon.ico", - ExpectedCode: http.StatusOK, - }, - { - Name: "frontend-home", - Path: "/", - ExpectedCode: http.StatusOK, - }, - { - Name: "frontend-assets", - Path: "/js/app.js", - ExpectedCode: http.StatusOK, - }, - { - Name: "frontend-service", - Path: "/services/core_frontend", - ExpectedCode: http.StatusOK, - }, - } - for _, scenario := range scenarios { - t.Run(scenario.Name, func(t *testing.T) { - request, _ := http.NewRequest("GET", scenario.Path, nil) - if scenario.Gzip { - request.Header.Set("Accept-Encoding", "gzip") - } - responseRecorder := httptest.NewRecorder() - router.ServeHTTP(responseRecorder, request) - if responseRecorder.Code != scenario.ExpectedCode { - t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) - } - }) - } -} - func TestHandle(t *testing.T) { - defer storage.Get().Clear() - defer cache.Clear() cfg := &config.Config{ Web: &config.WebConfig{ Address: "0.0.0.0", @@ -307,70 +52,3 @@ func TestShutdown(t *testing.T) { t.Error("server should've been shut down") } } - -func TestServiceStatusesHandler(t *testing.T) { - defer storage.Get().Clear() - defer cache.Clear() - firstResult := &testSuccessfulResult - secondResult := &testUnsuccessfulResult - storage.Get().Insert(&testService, firstResult) - storage.Get().Insert(&testService, secondResult) - // Can't be bothered dealing with timezone issues on the worker that runs the automated tests - firstResult.Timestamp = time.Time{} - secondResult.Timestamp = time.Time{} - router := CreateRouter("../web/static", nil, nil, false) - - type Scenario struct { - Name string - Path string - ExpectedCode int - ExpectedBody string - } - scenarios := []Scenario{ - { - Name: "no-pagination", - Path: "/api/v1/services/statuses", - ExpectedCode: http.StatusOK, - ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"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"}],"events":[]}]`, - }, - { - Name: "pagination-first-result", - Path: "/api/v1/services/statuses?page=1&pageSize=1", - ExpectedCode: http.StatusOK, - ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`, - }, - { - Name: "pagination-second-result", - Path: "/api/v1/services/statuses?page=2&pageSize=1", - ExpectedCode: http.StatusOK, - ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"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"}],"events":[]}]`, - }, - { - Name: "pagination-no-results", - Path: "/api/v1/services/statuses?page=5&pageSize=20", - ExpectedCode: http.StatusOK, - ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[],"events":[]}]`, - }, - { - Name: "invalid-pagination-should-fall-back-to-default", - Path: "/api/v1/services/statuses?page=INVALID&pageSize=INVALID", - ExpectedCode: http.StatusOK, - ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"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"}],"events":[]}]`, - }, - } - - for _, scenario := range scenarios { - t.Run(scenario.Name, func(t *testing.T) { - request, _ := http.NewRequest("GET", scenario.Path, nil) - responseRecorder := httptest.NewRecorder() - router.ServeHTTP(responseRecorder, request) - if responseRecorder.Code != scenario.ExpectedCode { - t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) - } - output := responseRecorder.Body.String() - if output != scenario.ExpectedBody { - t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output) - } - }) - } -} diff --git a/controller/badge.go b/controller/handler/badge.go similarity index 92% rename from controller/badge.go rename to controller/handler/badge.go index 3c67ea06..570f070f 100644 --- a/controller/badge.go +++ b/controller/handler/badge.go @@ -1,4 +1,4 @@ -package controller +package handler import ( "fmt" @@ -21,10 +21,10 @@ const ( badgeColorHexVeryBad = "#c7130a" ) -// uptimeBadgeHandler handles the automatic generation of badge based on the group name and service name passed. +// UptimeBadge handles the automatic generation of badge based on the group name and service name passed. // // Valid values for {duration}: 7d, 24h, 1h -func uptimeBadgeHandler(writer http.ResponseWriter, request *http.Request) { +func UptimeBadge(writer http.ResponseWriter, request *http.Request) { variables := mux.Vars(request) duration := variables["duration"] var from time.Time @@ -60,6 +60,45 @@ func uptimeBadgeHandler(writer http.ResponseWriter, request *http.Request) { _, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime)) } +// ResponseTimeBadge handles the automatic generation of badge based on the group name and service name passed. +// +// Valid values for {duration}: 7d, 24h, 1h +func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) { + variables := mux.Vars(request) + duration := variables["duration"] + var from time.Time + switch duration { + case "7d": + from = time.Now().Add(-7 * 24 * time.Hour) + case "24h": + from = time.Now().Add(-24 * time.Hour) + case "1h": + from = time.Now().Add(-time.Hour) + default: + http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest) + return + } + key := variables["key"] + averageResponseTime, err := storage.Get().GetAverageResponseTimeByKey(key, from, time.Now()) + if err != nil { + if err == common.ErrServiceNotFound { + writer.WriteHeader(http.StatusNotFound) + } else if err == common.ErrInvalidTimeRange { + writer.WriteHeader(http.StatusBadRequest) + } else { + writer.WriteHeader(http.StatusInternalServerError) + } + _, _ = writer.Write([]byte(err.Error())) + return + } + formattedDate := time.Now().Format(http.TimeFormat) + writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + writer.Header().Set("Date", formattedDate) + writer.Header().Set("Expires", formattedDate) + writer.Header().Set("Content-Type", "image/svg+xml") + _, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime)) +} + func generateUptimeBadgeSVG(duration string, uptime float64) []byte { var labelWidth, valueWidth, valueWidthAdjustment int switch duration { @@ -126,46 +165,6 @@ func getBadgeColorFromUptime(uptime float64) string { return badgeColorHexVeryBad } -// responseTimeBadgeHandler handles the automatic generation of badge based on the group name and service name passed. -// -// Valid values for {duration}: 7d, 24h, 1h -func responseTimeBadgeHandler(writer http.ResponseWriter, request *http.Request) { - variables := mux.Vars(request) - duration := variables["duration"] - var from time.Time - switch duration { - case "7d": - from = time.Now().Add(-7 * 24 * time.Hour) - case "24h": - from = time.Now().Add(-24 * time.Hour) - case "1h": - from = time.Now().Add(-time.Hour) - default: - writer.WriteHeader(http.StatusBadRequest) - _, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h")) - return - } - key := variables["key"] - averageResponseTime, err := storage.Get().GetAverageResponseTimeByKey(key, from, time.Now()) - if err != nil { - if err == common.ErrServiceNotFound { - writer.WriteHeader(http.StatusNotFound) - } else if err == common.ErrInvalidTimeRange { - writer.WriteHeader(http.StatusBadRequest) - } else { - writer.WriteHeader(http.StatusInternalServerError) - } - _, _ = writer.Write([]byte(err.Error())) - return - } - formattedDate := time.Now().Format(http.TimeFormat) - writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - writer.Header().Set("Date", formattedDate) - writer.Header().Set("Expires", formattedDate) - writer.Header().Set("Content-Type", "image/svg+xml") - _, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime)) -} - func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []byte { var labelWidth, valueWidth int switch duration { diff --git a/controller/handler/badge_test.go b/controller/handler/badge_test.go new file mode 100644 index 00000000..697a7650 --- /dev/null +++ b/controller/handler/badge_test.go @@ -0,0 +1,221 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/TwinProduction/gatus/config" + "github.com/TwinProduction/gatus/core" + "github.com/TwinProduction/gatus/storage" + "github.com/TwinProduction/gatus/watchdog" +) + +func TestUptimeBadge(t *testing.T) { + defer storage.Get().Clear() + defer cache.Clear() + cfg := &config.Config{ + Metrics: true, + Services: []*core.Service{ + { + Name: "frontend", + Group: "core", + }, + { + Name: "backend", + Group: "core", + }, + }, + } + watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) + router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) + type Scenario struct { + Name string + Path string + ExpectedCode int + Gzip bool + } + scenarios := []Scenario{ + { + Name: "badge-uptime-1h", + Path: "/api/v1/services/core_frontend/uptimes/1h/badge.svg", + ExpectedCode: http.StatusOK, + }, + { + Name: "badge-uptime-24h", + Path: "/api/v1/services/core_backend/uptimes/24h/badge.svg", + ExpectedCode: http.StatusOK, + }, + { + Name: "badge-uptime-7d", + Path: "/api/v1/services/core_frontend/uptimes/7d/badge.svg", + ExpectedCode: http.StatusOK, + }, + { + Name: "badge-uptime-with-invalid-duration", + Path: "/api/v1/services/core_backend/uptimes/3d/badge.svg", + ExpectedCode: http.StatusBadRequest, + }, + { + Name: "badge-uptime-for-invalid-key", + Path: "/api/v1/services/invalid_key/uptimes/7d/badge.svg", + ExpectedCode: http.StatusNotFound, + }, + { + Name: "badge-response-time-1h", + Path: "/api/v1/services/core_frontend/response-times/1h/badge.svg", + ExpectedCode: http.StatusOK, + }, + { + Name: "badge-response-time-24h", + Path: "/api/v1/services/core_backend/response-times/24h/badge.svg", + ExpectedCode: http.StatusOK, + }, + { + Name: "badge-response-time-7d", + Path: "/api/v1/services/core_frontend/response-times/7d/badge.svg", + ExpectedCode: http.StatusOK, + }, + { + Name: "badge-response-time-with-invalid-duration", + Path: "/api/v1/services/core_backend/response-times/3d/badge.svg", + ExpectedCode: http.StatusBadRequest, + }, + { + Name: "badge-response-time-for-invalid-key", + Path: "/api/v1/services/invalid_key/response-times/7d/badge.svg", + ExpectedCode: http.StatusNotFound, + }, + { + Name: "chart-response-time-24h", + Path: "/api/v1/services/core_backend/response-times/24h/chart.svg", + ExpectedCode: http.StatusOK, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + request, _ := http.NewRequest("GET", scenario.Path, nil) + if scenario.Gzip { + request.Header.Set("Accept-Encoding", "gzip") + } + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) + } + }) + } +} + +func TestGetBadgeColorFromUptime(t *testing.T) { + scenarios := []struct { + Uptime float64 + ExpectedColor string + }{ + { + Uptime: 1, + ExpectedColor: badgeColorHexAwesome, + }, + { + Uptime: 0.99, + ExpectedColor: badgeColorHexAwesome, + }, + { + Uptime: 0.97, + ExpectedColor: badgeColorHexGreat, + }, + { + Uptime: 0.95, + ExpectedColor: badgeColorHexGreat, + }, + { + Uptime: 0.93, + ExpectedColor: badgeColorHexGood, + }, + { + Uptime: 0.9, + ExpectedColor: badgeColorHexGood, + }, + { + Uptime: 0.85, + ExpectedColor: badgeColorHexPassable, + }, + { + Uptime: 0.7, + ExpectedColor: badgeColorHexBad, + }, + { + Uptime: 0.65, + ExpectedColor: badgeColorHexBad, + }, + { + Uptime: 0.6, + ExpectedColor: badgeColorHexVeryBad, + }, + } + for _, scenario := range scenarios { + t.Run("uptime-"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) { + if getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor { + t.Errorf("expected %s from %f, got %v", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime)) + } + }) + } +} + +func TestGetBadgeColorFromResponseTime(t *testing.T) { + scenarios := []struct { + ResponseTime int + ExpectedColor string + }{ + { + ResponseTime: 10, + ExpectedColor: badgeColorHexAwesome, + }, + { + ResponseTime: 50, + ExpectedColor: badgeColorHexAwesome, + }, + { + ResponseTime: 75, + ExpectedColor: badgeColorHexGreat, + }, + { + ResponseTime: 150, + ExpectedColor: badgeColorHexGreat, + }, + { + ResponseTime: 201, + ExpectedColor: badgeColorHexGood, + }, + { + ResponseTime: 300, + ExpectedColor: badgeColorHexGood, + }, + { + ResponseTime: 301, + ExpectedColor: badgeColorHexPassable, + }, + { + ResponseTime: 450, + ExpectedColor: badgeColorHexPassable, + }, + { + ResponseTime: 700, + ExpectedColor: badgeColorHexBad, + }, + { + ResponseTime: 1500, + ExpectedColor: badgeColorHexVeryBad, + }, + } + for _, scenario := range scenarios { + t.Run("response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) { + if getBadgeColorFromResponseTime(scenario.ResponseTime) != scenario.ExpectedColor { + t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime)) + } + }) + } +} diff --git a/controller/chart.go b/controller/handler/chart.go similarity index 94% rename from controller/chart.go rename to controller/handler/chart.go index bca979eb..c335d9dd 100644 --- a/controller/chart.go +++ b/controller/handler/chart.go @@ -1,4 +1,4 @@ -package controller +package handler import ( "log" @@ -29,7 +29,7 @@ var ( } ) -func responseTimeChartHandler(writer http.ResponseWriter, r *http.Request) { +func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) duration := vars["duration"] var from time.Time @@ -115,7 +115,7 @@ func responseTimeChartHandler(writer http.ResponseWriter, r *http.Request) { } writer.Header().Set("Content-Type", "image/svg+xml") if err := graph.Render(chart.SVG, writer); err != nil { - log.Println("[controller][responseTimeChartHandler] Failed to render response time chart:", err.Error()) + log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error()) return } } diff --git a/controller/handler/chart_test.go b/controller/handler/chart_test.go new file mode 100644 index 00000000..0354c73e --- /dev/null +++ b/controller/handler/chart_test.go @@ -0,0 +1,75 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/TwinProduction/gatus/config" + "github.com/TwinProduction/gatus/core" + "github.com/TwinProduction/gatus/storage" + "github.com/TwinProduction/gatus/watchdog" +) + +func TestResponseTimeChart(t *testing.T) { + defer storage.Get().Clear() + defer cache.Clear() + cfg := &config.Config{ + Metrics: true, + Services: []*core.Service{ + { + Name: "frontend", + Group: "core", + }, + { + Name: "backend", + Group: "core", + }, + }, + } + watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) + router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) + type Scenario struct { + Name string + Path string + ExpectedCode int + Gzip bool + } + scenarios := []Scenario{ + { + Name: "chart-response-time-24h", + Path: "/api/v1/services/core_backend/response-times/24h/chart.svg", + ExpectedCode: http.StatusOK, + }, + { + Name: "chart-response-time-7d", + Path: "/api/v1/services/core_frontend/response-times/7d/chart.svg", + ExpectedCode: http.StatusOK, + }, + { + Name: "chart-response-time-with-invalid-duration", + Path: "/api/v1/services/core_backend/response-times/3d/chart.svg", + ExpectedCode: http.StatusBadRequest, + }, + { + Name: "chart-response-time-for-invalid-key", + Path: "/api/v1/services/invalid_key/response-times/7d/chart.svg", + ExpectedCode: http.StatusNotFound, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + request, _ := http.NewRequest("GET", scenario.Path, nil) + if scenario.Gzip { + request.Header.Set("Accept-Encoding", "gzip") + } + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) + } + }) + } +} diff --git a/controller/cors.go b/controller/handler/cors.go similarity index 70% rename from controller/cors.go rename to controller/handler/cors.go index 7e938d47..9a1fbe57 100644 --- a/controller/cors.go +++ b/controller/handler/cors.go @@ -1,8 +1,8 @@ -package controller +package handler import "net/http" -func developmentCorsHandler(next http.Handler) http.Handler { +func DevelopmentCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081") next.ServeHTTP(w, r) diff --git a/controller/favicon.go b/controller/handler/favicon.go similarity index 54% rename from controller/favicon.go rename to controller/handler/favicon.go index afab6a68..ee642c64 100644 --- a/controller/favicon.go +++ b/controller/handler/favicon.go @@ -1,11 +1,11 @@ -package controller +package handler import ( "net/http" ) -// favIconHandler handles requests for /favicon.ico -func favIconHandler(staticFolder string) http.HandlerFunc { +// FavIcon handles requests for /favicon.ico +func FavIcon(staticFolder string) http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { http.ServeFile(writer, request, staticFolder+"/favicon.ico") } diff --git a/controller/handler/favicon_test.go b/controller/handler/favicon_test.go new file mode 100644 index 00000000..2fc16259 --- /dev/null +++ b/controller/handler/favicon_test.go @@ -0,0 +1,33 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestFavIcon(t *testing.T) { + router := CreateRouter("../../web/static", nil, nil, false) + type Scenario struct { + Name string + Path string + ExpectedCode int + } + scenarios := []Scenario{ + { + Name: "favicon", + Path: "/favicon.ico", + ExpectedCode: http.StatusOK, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + request, _ := http.NewRequest("GET", scenario.Path, nil) + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) + } + }) + } +} diff --git a/controller/gzip.go b/controller/handler/gzip.go similarity index 98% rename from controller/gzip.go rename to controller/handler/gzip.go index b3c103c0..e2868b49 100644 --- a/controller/gzip.go +++ b/controller/handler/gzip.go @@ -1,4 +1,4 @@ -package controller +package handler import ( "compress/gzip" diff --git a/controller/handler/handler.go b/controller/handler/handler.go new file mode 100644 index 00000000..25479648 --- /dev/null +++ b/controller/handler/handler.go @@ -0,0 +1,41 @@ +package handler + +import ( + "net/http" + + "github.com/TwinProduction/gatus/config" + "github.com/TwinProduction/gatus/security" + "github.com/TwinProduction/health" + "github.com/gorilla/mux" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *config.UIConfig, enabledMetrics bool) *mux.Router { + router := mux.NewRouter() + if enabledMetrics { + router.Handle("/metrics", promhttp.Handler()).Methods("GET") + } + router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") + router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET") + // Endpoints + router.HandleFunc("/api/v1/services/statuses", secureIfNecessary(securityConfig, ServiceStatuses)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already + router.HandleFunc("/api/v1/services/{key}/statuses", secureIfNecessary(securityConfig, GzipHandlerFunc(ServiceStatus))).Methods("GET") + // TODO: router.HandleFunc("/api/v1/services/{key}/uptimes", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceUptimesHandler))).Methods("GET") + // TODO: router.HandleFunc("/api/v1/services/{key}/events", secureIfNecessary(securityConfig, GzipHandlerFunc(serviceEventsHandler))).Methods("GET") + router.HandleFunc("/api/v1/services/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET") + router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET") + router.HandleFunc("/api/v1/services/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET") + // SPA + router.HandleFunc("/services/{service}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") + router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") + // Everything else falls back on static content + router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder)))) + return router +} + +func secureIfNecessary(securityConfig *security.Config, handler http.HandlerFunc) http.HandlerFunc { + if securityConfig != nil && securityConfig.IsValid() { + return security.Handler(handler, securityConfig) + } + return handler +} diff --git a/controller/handler/handler_test.go b/controller/handler/handler_test.go new file mode 100644 index 00000000..207f8285 --- /dev/null +++ b/controller/handler/handler_test.go @@ -0,0 +1,58 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateRouter(t *testing.T) { + router := CreateRouter("../../web/static", nil, nil, true) + type Scenario struct { + Name string + Path string + ExpectedCode int + Gzip bool + } + scenarios := []Scenario{ + { + Name: "health", + Path: "/health", + ExpectedCode: http.StatusOK, + }, + { + Name: "metrics", + Path: "/metrics", + ExpectedCode: http.StatusOK, + }, + { + Name: "scripts", + Path: "/js/app.js", + ExpectedCode: http.StatusOK, + }, + { + Name: "scripts-gzipped", + Path: "/js/app.js", + ExpectedCode: http.StatusOK, + Gzip: true, + }, + { + Name: "index-redirect", + Path: "/index.html", + ExpectedCode: http.StatusMovedPermanently, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + request, _ := http.NewRequest("GET", scenario.Path, nil) + if scenario.Gzip { + request.Header.Set("Accept-Encoding", "gzip") + } + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) + } + }) + } +} diff --git a/controller/handler/service_status.go b/controller/handler/service_status.go new file mode 100644 index 00000000..2d7454e5 --- /dev/null +++ b/controller/handler/service_status.go @@ -0,0 +1,103 @@ +package handler + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/TwinProduction/gatus/storage" + "github.com/TwinProduction/gatus/storage/store/common" + "github.com/TwinProduction/gatus/storage/store/common/paging" + "github.com/TwinProduction/gocache" + "github.com/gorilla/mux" +) + +const ( + cacheTTL = 10 * time.Second +) + +var ( + cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut) +) + +// ServiceStatuses handles requests to retrieve all service statuses +// Due to the size of the response, this function leverages a cache. +// Must not be wrapped by GzipHandler +func ServiceStatuses(writer http.ResponseWriter, r *http.Request) { + page, pageSize := extractPageAndPageSizeFromRequest(r) + gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") + var exists bool + var value interface{} + if gzipped { + writer.Header().Set("Content-Encoding", "gzip") + value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize)) + } else { + value, exists = cache.Get(fmt.Sprintf("service-status-%d-%d", page, pageSize)) + } + var data []byte + if !exists { + var err error + buffer := &bytes.Buffer{} + gzipWriter := gzip.NewWriter(buffer) + serviceStatuses, err := storage.Get().GetAllServiceStatuses(paging.NewServiceStatusParams().WithResults(page, pageSize)) + if err != nil { + log.Printf("[handler][ServiceStatuses] Failed to retrieve service statuses: %s", err.Error()) + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + data, err = json.Marshal(serviceStatuses) + if err != nil { + log.Printf("[handler][ServiceStatuses] Unable to marshal object to JSON: %s", err.Error()) + http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError) + return + } + _, _ = gzipWriter.Write(data) + _ = gzipWriter.Close() + gzippedData := buffer.Bytes() + cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d", page, pageSize), data, cacheTTL) + cache.SetWithTTL(fmt.Sprintf("service-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL) + if gzipped { + data = gzippedData + } + } else { + data = value.([]byte) + } + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(data) +} + +// ServiceStatus retrieves a single ServiceStatus by group name and service name +func ServiceStatus(writer http.ResponseWriter, r *http.Request) { + page, pageSize := extractPageAndPageSizeFromRequest(r) + vars := mux.Vars(r) + serviceStatus, err := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents)) + if err != nil { + if err == common.ErrServiceNotFound { + http.Error(writer, err.Error(), http.StatusNotFound) + return + } + log.Printf("[handler][ServiceStatus] Failed to retrieve service status: %s", err.Error()) + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + if serviceStatus == nil { + log.Printf("[handler][ServiceStatus] Service with key=%s not found", vars["key"]) + http.Error(writer, "not found", http.StatusNotFound) + return + } + output, err := json.Marshal(serviceStatus) + if err != nil { + log.Printf("[handler][ServiceStatus] Unable to marshal object to JSON: %s", err.Error()) + http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError) + return + } + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(output) +} diff --git a/controller/handler/service_status_test.go b/controller/handler/service_status_test.go new file mode 100644 index 00000000..a85e8034 --- /dev/null +++ b/controller/handler/service_status_test.go @@ -0,0 +1,215 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/TwinProduction/gatus/config" + "github.com/TwinProduction/gatus/core" + "github.com/TwinProduction/gatus/storage" + "github.com/TwinProduction/gatus/watchdog" +) + +var ( + firstCondition = core.Condition("[STATUS] == 200") + secondCondition = core.Condition("[RESPONSE_TIME] < 500") + thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") + + timestamp = time.Now() + + testService = core.Service{ + Name: "name", + Group: "group", + URL: "https://example.org/what/ever", + Method: "GET", + Body: "body", + Interval: 30 * time.Second, + Conditions: []*core.Condition{&firstCondition, &secondCondition, &thirdCondition}, + Alerts: nil, + NumberOfFailuresInARow: 0, + NumberOfSuccessesInARow: 0, + } + testSuccessfulResult = core.Result{ + Hostname: "example.org", + IP: "127.0.0.1", + HTTPStatus: 200, + Errors: nil, + Connected: true, + Success: true, + Timestamp: timestamp, + Duration: 150 * time.Millisecond, + CertificateExpiration: 10 * time.Hour, + ConditionResults: []*core.ConditionResult{ + { + Condition: "[STATUS] == 200", + Success: true, + }, + { + Condition: "[RESPONSE_TIME] < 500", + Success: true, + }, + { + Condition: "[CERTIFICATE_EXPIRATION] < 72h", + Success: true, + }, + }, + } + testUnsuccessfulResult = core.Result{ + Hostname: "example.org", + IP: "127.0.0.1", + HTTPStatus: 200, + Errors: []string{"error-1", "error-2"}, + Connected: true, + Success: false, + Timestamp: timestamp, + Duration: 750 * time.Millisecond, + CertificateExpiration: 10 * time.Hour, + ConditionResults: []*core.ConditionResult{ + { + Condition: "[STATUS] == 200", + Success: true, + }, + { + Condition: "[RESPONSE_TIME] < 500", + Success: false, + }, + { + Condition: "[CERTIFICATE_EXPIRATION] < 72h", + Success: false, + }, + }, + } +) + +func TestServiceStatus(t *testing.T) { + defer storage.Get().Clear() + defer cache.Clear() + cfg := &config.Config{ + Metrics: true, + Services: []*core.Service{ + { + Name: "frontend", + Group: "core", + }, + { + Name: "backend", + Group: "core", + }, + }, + } + watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) + router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) + + type Scenario struct { + Name string + Path string + ExpectedCode int + Gzip bool + } + scenarios := []Scenario{ + { + Name: "service-status", + Path: "/api/v1/services/core_frontend/statuses", + ExpectedCode: http.StatusOK, + }, + { + Name: "service-status-gzip", + Path: "/api/v1/services/core_frontend/statuses", + ExpectedCode: http.StatusOK, + Gzip: true, + }, + { + Name: "service-status-pagination", + Path: "/api/v1/services/core_frontend/statuses?page=1&pageSize=20", + ExpectedCode: http.StatusOK, + }, + { + Name: "service-status-for-invalid-key", + Path: "/api/v1/services/invalid_key/statuses", + ExpectedCode: http.StatusNotFound, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + request, _ := http.NewRequest("GET", scenario.Path, nil) + if scenario.Gzip { + request.Header.Set("Accept-Encoding", "gzip") + } + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) + } + }) + } +} + +func TestServiceStatuses(t *testing.T) { + defer storage.Get().Clear() + defer cache.Clear() + firstResult := &testSuccessfulResult + secondResult := &testUnsuccessfulResult + storage.Get().Insert(&testService, firstResult) + storage.Get().Insert(&testService, secondResult) + // Can't be bothered dealing with timezone issues on the worker that runs the automated tests + firstResult.Timestamp = time.Time{} + secondResult.Timestamp = time.Time{} + router := CreateRouter("../../web/static", nil, nil, false) + + type Scenario struct { + Name string + Path string + ExpectedCode int + ExpectedBody string + } + scenarios := []Scenario{ + { + Name: "no-pagination", + Path: "/api/v1/services/statuses", + ExpectedCode: http.StatusOK, + ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"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"}],"events":[]}]`, + }, + { + Name: "pagination-first-result", + Path: "/api/v1/services/statuses?page=1&pageSize=1", + ExpectedCode: http.StatusOK, + ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":750000000,"errors":["error-1","error-2"],"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":false},{"condition":"[CERTIFICATE_EXPIRATION] \u003c 72h","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"events":[]}]`, + }, + { + Name: "pagination-second-result", + Path: "/api/v1/services/statuses?page=2&pageSize=1", + ExpectedCode: http.StatusOK, + ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"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"}],"events":[]}]`, + }, + { + Name: "pagination-no-results", + Path: "/api/v1/services/statuses?page=5&pageSize=20", + ExpectedCode: http.StatusOK, + ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[],"events":[]}]`, + }, + { + Name: "invalid-pagination-should-fall-back-to-default", + Path: "/api/v1/services/statuses?page=INVALID&pageSize=INVALID", + ExpectedCode: http.StatusOK, + ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"errors":null,"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"}],"events":[]}]`, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + request, _ := http.NewRequest("GET", scenario.Path, nil) + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) + } + output := responseRecorder.Body.String() + if output != scenario.ExpectedBody { + t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output) + } + }) + } +} diff --git a/controller/spa.go b/controller/handler/spa.go similarity index 63% rename from controller/spa.go rename to controller/handler/spa.go index ae139602..a66b4b56 100644 --- a/controller/spa.go +++ b/controller/handler/spa.go @@ -1,4 +1,4 @@ -package controller +package handler import ( "html/template" @@ -8,18 +8,18 @@ import ( "github.com/TwinProduction/gatus/config" ) -func spaHandler(staticFolder string, ui *config.UIConfig) http.HandlerFunc { +func SinglePageApplication(staticFolder string, ui *config.UIConfig) http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { t, err := template.ParseFiles(staticFolder + "/index.html") if err != nil { - log.Println("[controller][spaHandler] Failed to parse template:", err.Error()) + log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error()) http.ServeFile(writer, request, staticFolder+"/index.html") return } writer.Header().Set("Content-Type", "text/html") err = t.Execute(writer, ui) if err != nil { - log.Println("[controller][spaHandler] Failed to parse template:", err.Error()) + log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error()) http.ServeFile(writer, request, staticFolder+"/index.html") return } diff --git a/controller/handler/spa_test.go b/controller/handler/spa_test.go new file mode 100644 index 00000000..d75c68f7 --- /dev/null +++ b/controller/handler/spa_test.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/TwinProduction/gatus/config" + "github.com/TwinProduction/gatus/core" + "github.com/TwinProduction/gatus/storage" + "github.com/TwinProduction/gatus/watchdog" +) + +func TestSinglePageApplication(t *testing.T) { + defer storage.Get().Clear() + defer cache.Clear() + cfg := &config.Config{ + Metrics: true, + Services: []*core.Service{ + { + Name: "frontend", + Group: "core", + }, + { + Name: "backend", + Group: "core", + }, + }, + } + watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) + router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) + type Scenario struct { + Name string + Path string + ExpectedCode int + Gzip bool + } + scenarios := []Scenario{ + { + Name: "frontend-home", + Path: "/", + ExpectedCode: http.StatusOK, + }, + { + Name: "frontend-service", + Path: "/services/core_frontend", + ExpectedCode: http.StatusOK, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + request, _ := http.NewRequest("GET", scenario.Path, nil) + if scenario.Gzip { + request.Header.Set("Accept-Encoding", "gzip") + } + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) + } + }) + } +} diff --git a/controller/util.go b/controller/handler/util.go similarity index 98% rename from controller/util.go rename to controller/handler/util.go index b15571fc..32c862b9 100644 --- a/controller/util.go +++ b/controller/handler/util.go @@ -1,4 +1,4 @@ -package controller +package handler import ( "net/http" diff --git a/controller/util_test.go b/controller/handler/util_test.go similarity index 98% rename from controller/util_test.go rename to controller/handler/util_test.go index 948c1c3c..1648d148 100644 --- a/controller/util_test.go +++ b/controller/handler/util_test.go @@ -1,4 +1,4 @@ -package controller +package handler import ( "fmt"