diff --git a/controller/controller.go b/controller/controller.go index 4dbc609a..7ca9973c 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -3,6 +3,7 @@ package controller import ( "bytes" "compress/gzip" + "encoding/json" "fmt" "log" "net/http" @@ -53,12 +54,9 @@ func Handle() { // CreateRouter creates the router for the http server func CreateRouter(cfg *config.Config) *mux.Router { router := mux.NewRouter() - statusesHandler := serviceStatusesHandler - if cfg.Security != nil && cfg.Security.IsValid() { - statusesHandler = security.Handler(serviceStatusesHandler, cfg.Security) - } router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET") // favicon needs to be always served from the root - router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), statusesHandler).Methods("GET") + router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), secureIfNecessary(cfg, serviceStatusesHandler)).Methods("GET") + router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses/{key}"), secureIfNecessary(cfg, GzipHandlerFunc(serviceStatusHandler))).Methods("GET") router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/badges/uptime/{duration}/{identifier}"), badgeHandler).Methods("GET") router.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler).Methods("GET") router.PathPrefix(cfg.Web.ContextRoot).Handler(GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./static"))))) @@ -68,6 +66,16 @@ func CreateRouter(cfg *config.Config) *mux.Router { return router } +func secureIfNecessary(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc { + if cfg.Security != nil && cfg.Security.IsValid() { + return security.Handler(serviceStatusesHandler, cfg.Security) + } + 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) { gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") var exists bool @@ -85,7 +93,7 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) { gzipWriter := gzip.NewWriter(buffer) data, err = watchdog.GetServiceStatusesAsJSON() if err != nil { - log.Printf("[main][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error()) + log.Printf("[controller][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error()) writer.WriteHeader(http.StatusInternalServerError) _, _ = writer.Write([]byte("Unable to marshal object to JSON")) return @@ -106,6 +114,28 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) { _, _ = writer.Write(data) } +// serviceStatusHandler retrieves a single ServiceStatus by group name and service name +func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + serviceStatus := watchdog.GetServiceStatusByKey(vars["key"]) + if serviceStatus == nil { + log.Printf("[controller][serviceStatusHandler] Service with key=%s not found", vars["key"]) + writer.WriteHeader(http.StatusNotFound) + _, _ = writer.Write([]byte("not found")) + return + } + data, err := json.Marshal(serviceStatus) + if err != nil { + log.Printf("[controller][serviceStatusHandler] Unable to marshal object to JSON: %s", err.Error()) + writer.WriteHeader(http.StatusInternalServerError) + _, _ = writer.Write([]byte("Unable to marshal object to JSON")) + return + } + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(data) +} + func healthHandler(writer http.ResponseWriter, _ *http.Request) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) diff --git a/controller/gzip.go b/controller/gzip.go index 3cdb34a0..b3c103c0 100644 --- a/controller/gzip.go +++ b/controller/gzip.go @@ -32,10 +32,18 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) { return w.Writer.Write(b) } -// GzipHandler compresses the response of a given handler if the request's headers specify that the client +// GzipHandler compresses the response of a given http.Handler if the request's headers specify that the client // supports gzip encoding func GzipHandler(next http.Handler) http.Handler { - return http.HandlerFunc(func(writer http.ResponseWriter, r *http.Request) { + return GzipHandlerFunc(func(writer http.ResponseWriter, r *http.Request) { + next.ServeHTTP(writer, r) + }) +} + +// GzipHandlerFunc compresses the response of a given http.HandlerFunc if the request's headers specify that the client +// supports gzip encoding +func GzipHandlerFunc(next http.HandlerFunc) http.HandlerFunc { + return func(writer http.ResponseWriter, r *http.Request) { // If the request doesn't specify that it supports gzip, then don't compress it if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { next.ServeHTTP(writer, r) @@ -47,5 +55,5 @@ func GzipHandler(next http.Handler) http.Handler { gz.Reset(writer) defer gz.Close() next.ServeHTTP(&gzipResponseWriter{ResponseWriter: writer, Writer: gz}, r) - }) + } } diff --git a/core/service-status.go b/core/service-status.go index d015a717..4d6be667 100644 --- a/core/service-status.go +++ b/core/service-status.go @@ -1,5 +1,7 @@ package core +import "github.com/TwinProduction/gatus/util" + // ServiceStatus contains the evaluation Results of a Service type ServiceStatus struct { // Name of the service @@ -8,6 +10,9 @@ type ServiceStatus struct { // Group the service is a part of. Used for grouping multiple services together on the front end. Group string `json:"group,omitempty"` + // Key is the key representing the ServiceStatus + Key string `json:"key"` + // Results is the list of service evaluation results Results []*Result `json:"results"` @@ -20,6 +25,7 @@ func NewServiceStatus(service *Service) *ServiceStatus { return &ServiceStatus{ Name: service.Name, Group: service.Group, + Key: util.ConvertGroupAndServiceToKey(service.Group, service.Name), Results: make([]*Result, 0), Uptime: NewUptime(), } diff --git a/core/service-status_test.go b/core/service-status_test.go index 432e349d..6eee2e73 100644 --- a/core/service-status_test.go +++ b/core/service-status_test.go @@ -14,6 +14,9 @@ func TestNewServiceStatus(t *testing.T) { if serviceStatus.Group != service.Group { t.Errorf("expected %s, got %s", service.Group, serviceStatus.Group) } + if serviceStatus.Key != "group_name" { + t.Errorf("expected %s, got %s", "group_name", serviceStatus.Key) + } } func TestServiceStatus_AddResult(t *testing.T) { diff --git a/storage/memory.go b/storage/memory.go index 9f550a84..b1b4f6ec 100644 --- a/storage/memory.go +++ b/storage/memory.go @@ -2,10 +2,10 @@ package storage import ( "encoding/json" - "fmt" "sync" "github.com/TwinProduction/gatus/core" + "github.com/TwinProduction/gatus/util" ) // InMemoryStore implements an in-memory store @@ -32,8 +32,16 @@ func (ims *InMemoryStore) GetAllAsJSON() ([]byte, error) { } // GetServiceStatus returns the service status for a given service name in the given group -func (ims *InMemoryStore) GetServiceStatus(group, name string) *core.ServiceStatus { - key := fmt.Sprintf("%s_%s", group, name) +func (ims *InMemoryStore) GetServiceStatus(groupName, serviceName string) *core.ServiceStatus { + key := util.ConvertGroupAndServiceToKey(groupName, serviceName) + ims.serviceResultsMutex.RLock() + serviceStatus := ims.serviceStatuses[key] + ims.serviceResultsMutex.RUnlock() + return serviceStatus +} + +// GetServiceStatusByKey returns the service status for a given key +func (ims *InMemoryStore) GetServiceStatusByKey(key string) *core.ServiceStatus { ims.serviceResultsMutex.RLock() serviceStatus := ims.serviceStatuses[key] ims.serviceResultsMutex.RUnlock() @@ -42,7 +50,7 @@ func (ims *InMemoryStore) GetServiceStatus(group, name string) *core.ServiceStat // Insert inserts the observed result for the specified service into the in memory store func (ims *InMemoryStore) Insert(service *core.Service, result *core.Result) { - key := fmt.Sprintf("%s_%s", service.Group, service.Name) + key := util.ConvertGroupAndServiceToKey(service.Group, service.Name) ims.serviceResultsMutex.Lock() serviceStatus, exists := ims.serviceStatuses[key] if !exists { diff --git a/storage/memory_test.go b/storage/memory_test.go index fb0d5e91..1701a0b6 100644 --- a/storage/memory_test.go +++ b/storage/memory_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/TwinProduction/gatus/core" + "github.com/TwinProduction/gatus/util" ) var ( @@ -160,7 +161,6 @@ func TestInMemoryStore_GetServiceStatus(t *testing.T) { if serviceStatus.Uptime.LastSevenDays != 0.5 { t.Errorf("serviceStatus.Uptime.LastSevenDays should've been 0.5") } - fmt.Println(serviceStatus.Results[0].Timestamp.Format(time.RFC3339)) } func TestInMemoryStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) { @@ -181,6 +181,29 @@ func TestInMemoryStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) } } +func TestInMemoryStore_GetServiceStatusByKey(t *testing.T) { + store := NewInMemoryStore() + store.Insert(&testService, &testSuccessfulResult) + store.Insert(&testService, &testUnsuccessfulResult) + + serviceStatus := store.GetServiceStatusByKey(util.ConvertGroupAndServiceToKey(testService.Group, testService.Name)) + if serviceStatus == nil { + t.Fatalf("serviceStatus shouldn't have been nil") + } + if serviceStatus.Uptime == nil { + t.Fatalf("serviceStatus.Uptime shouldn't have been nil") + } + if serviceStatus.Uptime.LastHour != 0.5 { + t.Errorf("serviceStatus.Uptime.LastHour should've been 0.5") + } + if serviceStatus.Uptime.LastTwentyFourHours != 0.5 { + t.Errorf("serviceStatus.Uptime.LastTwentyFourHours should've been 0.5") + } + if serviceStatus.Uptime.LastSevenDays != 0.5 { + t.Errorf("serviceStatus.Uptime.LastSevenDays should've been 0.5") + } +} + func TestInMemoryStore_GetAllAsJSON(t *testing.T) { store := NewInMemoryStore() firstResult := &testSuccessfulResult @@ -194,7 +217,7 @@ func TestInMemoryStore_GetAllAsJSON(t *testing.T) { if err != nil { t.Fatal("shouldn't have returned an error, got", err.Error()) } - expectedOutput := `{"group_name":{"name":"name","group":"group","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"],"condition-results":[{"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"}],"uptime":{"7d":0.5,"24h":0.5,"1h":0.5}}}` + expectedOutput := `{"group_name":{"name":"name","group":"group","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"}],"uptime":{"7d":0.5,"24h":0.5,"1h":0.5}}}` if string(output) != expectedOutput { t.Errorf("expected:\n %s\n\ngot:\n %s", expectedOutput, string(output)) } diff --git a/util/key.go b/util/key.go new file mode 100644 index 00000000..053f26ff --- /dev/null +++ b/util/key.go @@ -0,0 +1,18 @@ +package util + +import "strings" + +// ConvertGroupAndServiceToKey converts a group and a service to a key +func ConvertGroupAndServiceToKey(group, service string) string { + return sanitize(group) + "_" + sanitize(service) +} + +func sanitize(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.ReplaceAll(s, "/", "-") + s = strings.ReplaceAll(s, "_", "-") + s = strings.ReplaceAll(s, ".", "-") + s = strings.ReplaceAll(s, ",", "-") + s = strings.ReplaceAll(s, " ", "-") + return s +} diff --git a/util/key_test.go b/util/key_test.go new file mode 100644 index 00000000..6cd04e90 --- /dev/null +++ b/util/key_test.go @@ -0,0 +1,36 @@ +package util + +import "testing" + +func TestConvertGroupAndServiceToKey(t *testing.T) { + type Scenario struct { + GroupName string + ServiceName string + ExpectedOutput string + } + scenarios := []Scenario{ + { + GroupName: "Core", + ServiceName: "Front End", + ExpectedOutput: "core_front-end", + }, + { + GroupName: "Load balancers", + ServiceName: "us-west-2", + ExpectedOutput: "load-balancers_us-west-2", + }, + { + GroupName: "a/b test", + ServiceName: "a", + ExpectedOutput: "a-b-test_a", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.ExpectedOutput, func(t *testing.T) { + output := ConvertGroupAndServiceToKey(scenario.GroupName, scenario.ServiceName) + if output != scenario.ExpectedOutput { + t.Errorf("Expected '%s', got '%s'", scenario.ExpectedOutput, output) + } + }) + } +} diff --git a/watchdog/watchdog.go b/watchdog/watchdog.go index 1c34e4f8..44cf1579 100644 --- a/watchdog/watchdog.go +++ b/watchdog/watchdog.go @@ -25,15 +25,20 @@ func GetServiceStatusesAsJSON() ([]byte, error) { return store.GetAllAsJSON() } -// GetUptimeByServiceGroupAndName returns the uptime of a service based on its group and name -func GetUptimeByServiceGroupAndName(group, name string) *core.Uptime { - serviceStatus := store.GetServiceStatus(group, name) +// GetUptimeByKey returns the uptime of a service based on the ServiceStatus key +func GetUptimeByKey(key string) *core.Uptime { + serviceStatus := store.GetServiceStatusByKey(key) if serviceStatus == nil { return nil } return serviceStatus.Uptime } +// GetServiceStatusByKey returns the uptime of a service based on its ServiceStatus key +func GetServiceStatusByKey(key string) *core.ServiceStatus { + return store.GetServiceStatusByKey(key) +} + // Monitor loops over each services and starts a goroutine to monitor each services separately func Monitor(cfg *config.Config) { for _, service := range cfg.Services { diff --git a/web/app/package-lock.json b/web/app/package-lock.json index 6fb9927e..334d10d8 100644 --- a/web/app/package-lock.json +++ b/web/app/package-lock.json @@ -12,11 +12,13 @@ "core-js": "^3.6.5", "postcss": "^7.0.35", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2", - "vue": "^3.0.0" + "vue": "^3.0.0", + "vue-router": "^4.0.0-0" }, "devDependencies": { "@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-eslint": "~4.5.0", + "@vue/cli-plugin-router": "~4.5.0", "@vue/cli-service": "~4.5.0", "@vue/compiler-sfc": "^3.0.0", "babel-eslint": "^10.1.0", @@ -14086,6 +14088,14 @@ "integrity": "sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ=", "dev": true }, + "node_modules/vue-router": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.3.tgz", + "integrity": "sha512-AD1OjtVPyQHTSpoRsEGfPpxRQwhAhxcacOYO3zJ3KNkYP/r09mileSp6kdMQKhZWP2cFsPR3E2M3PZguSN5/ww==", + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-style-loader": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz", @@ -17085,8 +17095,7 @@ "version": "4.5.11", "resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.11.tgz", "integrity": "sha512-JBPeZLubiSHbRkEKDj0tnLiU43AJ3vt6JULn4IKWH1XWZ6MFC8vElaP5/AA4O3Zko5caamDDBq3TRyxdA2ncUQ==", - "dev": true, - "requires": {} + "dev": true }, "@vue/cli-service": { "version": "4.5.11", @@ -17325,8 +17334,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz", "integrity": "sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==", - "dev": true, - "requires": {} + "dev": true }, "@vue/reactivity": { "version": "3.0.5", @@ -17572,8 +17580,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", - "dev": true, - "requires": {} + "dev": true }, "acorn-node": { "version": "1.8.2", @@ -17622,15 +17629,13 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", - "dev": true, - "requires": {} + "dev": true }, "ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "alphanum-sort": { "version": "1.0.2", @@ -26881,6 +26886,11 @@ } } }, + "vue-router": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.3.tgz", + "integrity": "sha512-AD1OjtVPyQHTSpoRsEGfPpxRQwhAhxcacOYO3zJ3KNkYP/r09mileSp6kdMQKhZWP2cFsPR3E2M3PZguSN5/ww==" + }, "vue-style-loader": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz", diff --git a/web/app/package.json b/web/app/package.json index 28b951bb..349a8684 100644 --- a/web/app/package.json +++ b/web/app/package.json @@ -13,11 +13,13 @@ "core-js": "^3.6.5", "postcss": "^7.0.35", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2", - "vue": "^3.0.0" + "vue": "^3.0.0", + "vue-router": "^4.0.0-0" }, "devDependencies": { "@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-eslint": "~4.5.0", + "@vue/cli-plugin-router": "~4.5.0", "@vue/cli-service": "~4.5.0", "@vue/compiler-sfc": "^3.0.0", "babel-eslint": "^10.1.0", diff --git a/web/app/src/App.vue b/web/app/src/App.vue index c2b1209e..962aa43d 100644 --- a/web/app/src/App.vue +++ b/web/app/src/App.vue @@ -1,51 +1,42 @@ @@ -53,9 +44,10 @@ export default { diff --git a/web/app/src/components/Service.vue b/web/app/src/components/Service.vue index 6036adb4..674c2db7 100644 --- a/web/app/src/components/Service.vue +++ b/web/app/src/components/Service.vue @@ -1,8 +1,8 @@