mirror of
https://github.com/TwiN/gatus.git
synced 2024-11-25 01:13:40 +01:00
Refactor controller and handlers
This commit is contained in:
parent
d86afb2381
commit
c57a930bf3
@ -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))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,35 +1,19 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/config"
|
"github.com/TwinProduction/gatus/config"
|
||||||
|
"github.com/TwinProduction/gatus/controller/handler"
|
||||||
"github.com/TwinProduction/gatus/security"
|
"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 (
|
var (
|
||||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
|
||||||
|
|
||||||
// server is the http.Server created by Handle.
|
// server is the http.Server created by Handle.
|
||||||
// The only reason it exists is for testing purposes.
|
// The only reason it exists is for testing purposes.
|
||||||
server *http.Server
|
server *http.Server
|
||||||
@ -37,9 +21,9 @@ var (
|
|||||||
|
|
||||||
// Handle creates the router and starts the server
|
// Handle creates the router and starts the server
|
||||||
func Handle(securityConfig *security.Config, webConfig *config.WebConfig, uiConfig *config.UIConfig, enableMetrics bool) {
|
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" {
|
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||||
router = developmentCorsHandler(router)
|
router = handler.DevelopmentCORS(router)
|
||||||
}
|
}
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
|
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
|
||||||
@ -62,111 +46,3 @@ func Shutdown() {
|
|||||||
server = nil
|
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)
|
|
||||||
}
|
|
||||||
|
@ -6,267 +6,12 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/TwinProduction/gatus/config"
|
"github.com/TwinProduction/gatus/config"
|
||||||
"github.com/TwinProduction/gatus/core"
|
"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) {
|
func TestHandle(t *testing.T) {
|
||||||
defer storage.Get().Clear()
|
|
||||||
defer cache.Clear()
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Web: &config.WebConfig{
|
Web: &config.WebConfig{
|
||||||
Address: "0.0.0.0",
|
Address: "0.0.0.0",
|
||||||
@ -307,70 +52,3 @@ func TestShutdown(t *testing.T) {
|
|||||||
t.Error("server should've been shut down")
|
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package controller
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -21,10 +21,10 @@ const (
|
|||||||
badgeColorHexVeryBad = "#c7130a"
|
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
|
// 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)
|
variables := mux.Vars(request)
|
||||||
duration := variables["duration"]
|
duration := variables["duration"]
|
||||||
var from time.Time
|
var from time.Time
|
||||||
@ -60,6 +60,45 @@ func uptimeBadgeHandler(writer http.ResponseWriter, request *http.Request) {
|
|||||||
_, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime))
|
_, _ = 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 {
|
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
||||||
var labelWidth, valueWidth, valueWidthAdjustment int
|
var labelWidth, valueWidth, valueWidthAdjustment int
|
||||||
switch duration {
|
switch duration {
|
||||||
@ -126,46 +165,6 @@ func getBadgeColorFromUptime(uptime float64) string {
|
|||||||
return badgeColorHexVeryBad
|
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 {
|
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int) []byte {
|
||||||
var labelWidth, valueWidth int
|
var labelWidth, valueWidth int
|
||||||
switch duration {
|
switch duration {
|
221
controller/handler/badge_test.go
Normal file
221
controller/handler/badge_test.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package controller
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"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)
|
vars := mux.Vars(r)
|
||||||
duration := vars["duration"]
|
duration := vars["duration"]
|
||||||
var from time.Time
|
var from time.Time
|
||||||
@ -115,7 +115,7 @@ func responseTimeChartHandler(writer http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||||
if err := graph.Render(chart.SVG, writer); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
75
controller/handler/chart_test.go
Normal file
75
controller/handler/chart_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
package controller
|
package handler
|
||||||
|
|
||||||
import "net/http"
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
|
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
@ -1,11 +1,11 @@
|
|||||||
package controller
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// favIconHandler handles requests for /favicon.ico
|
// FavIcon handles requests for /favicon.ico
|
||||||
func favIconHandler(staticFolder string) http.HandlerFunc {
|
func FavIcon(staticFolder string) http.HandlerFunc {
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
|
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
|
||||||
}
|
}
|
33
controller/handler/favicon_test.go
Normal file
33
controller/handler/favicon_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package controller
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"compress/gzip"
|
"compress/gzip"
|
41
controller/handler/handler.go
Normal file
41
controller/handler/handler.go
Normal file
@ -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
|
||||||
|
}
|
58
controller/handler/handler_test.go
Normal file
58
controller/handler/handler_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
103
controller/handler/service_status.go
Normal file
103
controller/handler/service_status.go
Normal file
@ -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)
|
||||||
|
}
|
215
controller/handler/service_status_test.go
Normal file
215
controller/handler/service_status_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package controller
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
@ -8,18 +8,18 @@ import (
|
|||||||
"github.com/TwinProduction/gatus/config"
|
"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) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
t, err := template.ParseFiles(staticFolder + "/index.html")
|
t, err := template.ParseFiles(staticFolder + "/index.html")
|
||||||
if err != nil {
|
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")
|
http.ServeFile(writer, request, staticFolder+"/index.html")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writer.Header().Set("Content-Type", "text/html")
|
writer.Header().Set("Content-Type", "text/html")
|
||||||
err = t.Execute(writer, ui)
|
err = t.Execute(writer, ui)
|
||||||
if err != nil {
|
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")
|
http.ServeFile(writer, request, staticFolder+"/index.html")
|
||||||
return
|
return
|
||||||
}
|
}
|
65
controller/handler/spa_test.go
Normal file
65
controller/handler/spa_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package controller
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
@ -1,4 +1,4 @@
|
|||||||
package controller
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
Loading…
Reference in New Issue
Block a user