Add page for individual service details
This commit is contained in:
		| @ -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) | ||||
|  | ||||
| @ -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) | ||||
| 	}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -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(), | ||||
| 	} | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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)) | ||||
| 	} | ||||
|  | ||||
							
								
								
									
										18
									
								
								util/key.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								util/key.go
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
| } | ||||
							
								
								
									
										36
									
								
								util/key_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								util/key_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -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) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| @ -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 { | ||||
|  | ||||
							
								
								
									
										32
									
								
								web/app/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										32
									
								
								web/app/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -1,51 +1,42 @@ | ||||
| <template> | ||||
|   <Services :serviceStatuses="serviceStatuses" :showStatusOnHover="true" @showTooltip="showTooltip"/> | ||||
|   <div class="container container-xs relative mx-auto rounded shadow-xl border my-3 p-5 text-left" id="global"> | ||||
|     <div class="mb-2"> | ||||
|       <div class="flex flex-wrap"> | ||||
|         <div class="w-2/3 text-left my-auto"> | ||||
|           <div class="title text-5xl font-light">Health Status</div> | ||||
|         </div> | ||||
|         <div class="w-1/3 flex justify-end"> | ||||
|           <img src="./assets/logo.png" alt="Gatus" style="min-width: 50px; max-width: 200px; width: 20%;"/> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <router-view @showTooltip="showTooltip"/> | ||||
|   </div> | ||||
|   <Tooltip :result="tooltip.result" :event="tooltip.event"/> | ||||
|   <Social/> | ||||
|   <Settings @refreshStatuses="fetchStatuses"/> | ||||
| </template> | ||||
|  | ||||
|  | ||||
| <script> | ||||
| import Social from './components/Social.vue' | ||||
| import Settings from './components/Settings.vue' | ||||
| import Services from './components/Services.vue'; | ||||
| import Tooltip from './components/Tooltip.vue'; | ||||
| import {SERVER_URL} from "./main.js"; | ||||
|  | ||||
| export default { | ||||
|   name: 'App', | ||||
|   components: { | ||||
|     Services, | ||||
|     Social, | ||||
|     Settings, | ||||
|     Tooltip | ||||
|   }, | ||||
|   methods: { | ||||
|     fetchStatuses() { | ||||
|       console.log("[App][fetchStatuses] Fetching statuses"); | ||||
|       fetch(`${SERVER_URL}/api/v1/statuses`) | ||||
|           .then(response => response.json()) | ||||
|           .then(data => { | ||||
|             if (JSON.stringify(this.serviceStatuses) !== JSON.stringify(data)) { | ||||
|               console.log(data); | ||||
|               this.serviceStatuses = data; | ||||
|             } | ||||
|           }); | ||||
|     }, | ||||
|     showTooltip(result, event) { | ||||
|       this.tooltip = {result: result, event: event}; | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       serviceStatuses: {}, | ||||
|       tooltip: {} | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     this.fetchStatuses(); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| @ -53,9 +44,10 @@ export default { | ||||
| <style> | ||||
| html, body { | ||||
|   background-color: #f7f9fb; | ||||
| } | ||||
|  | ||||
| html, body { | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| #global, #results { | ||||
|   max-width: 1200px; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| <template> | ||||
|   <div class='container px-3 py-3 border-l border-r border-t rounded-none'> | ||||
|   <div class='service container px-3 py-3 border-l border-r border-t rounded-none' v-if="data && data.results && data.results.length"> | ||||
|     <div class='flex flex-wrap mb-2'> | ||||
|       <div class='w-3/4'> | ||||
|         <span class='font-bold'>{{ data.name }}</span> <span class='text-gray-500 font-light'>- {{ data.results[data.results.length - 1].hostname }}</span> | ||||
|         <router-link :to="generatePath()" class="font-bold transition duration-200 ease-in-out hover:text-blue-900">{{ data.name }}</router-link> <span class='text-gray-500 font-light'>- {{ data.results[data.results.length - 1].hostname }}</span> | ||||
|       </div> | ||||
|       <div class='w-1/4 text-right'> | ||||
|         <span class='font-light status-min-max-ms'> | ||||
| @ -41,6 +41,7 @@ export default { | ||||
|     maximumNumberOfResults: Number, | ||||
|     data: Object, | ||||
|   }, | ||||
|   emits: ['showTooltip'], | ||||
|   methods: { | ||||
|     updateMinAndMaxResponseTimes() { | ||||
|       let minResponseTime = null; | ||||
| @ -73,6 +74,12 @@ export default { | ||||
|       } | ||||
|       return (differenceInMs/1000).toFixed(0) + " seconds ago"; | ||||
|     }, | ||||
|     generatePath() { | ||||
|       if (!this.data) { | ||||
|         return "/"; | ||||
|       } | ||||
|       return "/services/" + this.data.key; | ||||
|     }, | ||||
|     showTooltip(result, event) { | ||||
|       this.$emit('showTooltip', result, event); | ||||
|     } | ||||
| @ -96,6 +103,19 @@ export default { | ||||
|  | ||||
|  | ||||
| <style> | ||||
| .service:first-child { | ||||
|   border-top-left-radius: 3px; | ||||
|   border-top-right-radius: 3px; | ||||
| } | ||||
|  | ||||
| .service:last-child { | ||||
|   border-bottom-left-radius: 3px; | ||||
|   border-bottom-right-radius: 3px; | ||||
|   border-bottom-width: 1px; | ||||
|   border-color: #dee2e6; | ||||
|   border-style: solid; | ||||
| } | ||||
|  | ||||
| .status { | ||||
|   cursor: pointer; | ||||
|   transition: all 500ms ease-in-out; | ||||
|  | ||||
| @ -33,6 +33,7 @@ export default { | ||||
|     name: String, | ||||
|     services: Array | ||||
|   }, | ||||
|   emits: ['showTooltip'], | ||||
|   methods: { | ||||
|     healthCheck() { | ||||
|       if (this.services) { | ||||
|  | ||||
| @ -1,20 +1,8 @@ | ||||
| <template> | ||||
|   <div class="container mx-auto rounded shadow-xl border my-3 p-5 text-left" id="global"> | ||||
|     <div class="mb-2"> | ||||
|       <div class="flex flex-wrap"> | ||||
|         <div class="w-2/3 text-left my-auto"> | ||||
|           <div class="title font-light">Health Status</div> | ||||
|         </div> | ||||
|         <div class="w-1/3 flex justify-end"> | ||||
|           <img src="../assets/logo.png" alt="Gatus" style="min-width: 50px; max-width: 200px; width: 20%;"/> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div id="results"> | ||||
|       <slot v-for="serviceGroup in serviceGroups" :key="serviceGroup"> | ||||
|         <ServiceGroup :services="serviceGroup.services" :name="serviceGroup.name" @showTooltip="showTooltip" /> | ||||
|       </slot> | ||||
|     </div> | ||||
|   <div id="results"> | ||||
|     <slot v-for="serviceGroup in serviceGroups" :key="serviceGroup"> | ||||
|       <ServiceGroup :services="serviceGroup.services" :name="serviceGroup.name" @showTooltip="showTooltip"/> | ||||
|     </slot> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| @ -31,6 +19,7 @@ export default { | ||||
|     showStatusOnHover: Boolean, | ||||
|     serviceStatuses: Object | ||||
|   }, | ||||
|   emits: ['showTooltip'], | ||||
|   methods: { | ||||
|     process() { | ||||
|       let outputByGroup = {}; | ||||
| @ -45,7 +34,7 @@ export default { | ||||
|       let serviceGroups = []; | ||||
|       for (let name in outputByGroup) { | ||||
|         if (name !== 'undefined') { | ||||
|           serviceGroups.push({ name: name, services: outputByGroup[name]}) | ||||
|           serviceGroups.push({name: name, services: outputByGroup[name]}) | ||||
|         } | ||||
|       } | ||||
|       // Add all services that don't have a group at the end | ||||
| @ -74,29 +63,8 @@ export default { | ||||
|  | ||||
|  | ||||
| <style> | ||||
| #global { | ||||
|   max-width: 1140px; | ||||
| } | ||||
|  | ||||
| #results div.container:first-child { | ||||
|   border-top-left-radius: 3px; | ||||
|   border-top-right-radius: 3px; | ||||
| } | ||||
|  | ||||
| #results div.container:last-child { | ||||
|   border-bottom-left-radius: 3px; | ||||
|   border-bottom-right-radius: 3px; | ||||
|   border-bottom-width: 1px; | ||||
|   border-color: #dee2e6; | ||||
|   border-style: solid; | ||||
| } | ||||
|  | ||||
| #results .service-group-content > div:nth-child(1) { | ||||
| .service-group-content > div:nth-child(1) { | ||||
|   border-top-left-radius: 0; | ||||
|   border-top-right-radius: 0; | ||||
| } | ||||
|  | ||||
| .title { | ||||
|   font-size: 2.5rem; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| @ -25,14 +25,14 @@ export default { | ||||
|     setRefreshInterval(seconds) { | ||||
|       let that = this; | ||||
|       this.refreshIntervalHandler = setInterval(function() { | ||||
|         that.refreshStatuses(); | ||||
|         that.refreshData(); | ||||
|       }, seconds * 1000); | ||||
|     }, | ||||
|     refreshStatuses() { | ||||
|       this.$emit('refreshStatuses'); | ||||
|     refreshData() { | ||||
|       this.$emit('refreshData'); | ||||
|     }, | ||||
|     handleChangeRefreshInterval() { | ||||
|       this.refreshStatuses(); | ||||
|       this.refreshData(); | ||||
|       clearInterval(this.refreshIntervalHandler); | ||||
|       this.setRefreshInterval(this.$refs.refreshInterval.value); | ||||
|     } | ||||
| @ -40,6 +40,9 @@ export default { | ||||
|   created() { | ||||
|     this.setRefreshInterval(this.refreshInterval); | ||||
|   }, | ||||
|   unmounted() { | ||||
|     clearInterval(this.refreshIntervalHandler); | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       refreshInterval: 30, | ||||
|  | ||||
| @ -1,7 +1,8 @@ | ||||
| import { createApp } from 'vue' | ||||
| import App from './App.vue' | ||||
| import './index.css' | ||||
| import router from './router' | ||||
|  | ||||
| export const SERVER_URL = process.env.NODE_ENV === 'production' ? '.' : 'http://localhost:8080' | ||||
|  | ||||
| createApp(App).mount('#app') | ||||
| createApp(App).use(router).mount('#app') | ||||
|  | ||||
							
								
								
									
										23
									
								
								web/app/src/router/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								web/app/src/router/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| import { createRouter, createWebHistory } from 'vue-router' | ||||
| import Home from '../views/Home.vue' | ||||
| import Details from "@/views/Details"; | ||||
|  | ||||
| const routes = [ | ||||
|   { | ||||
|     path: '/', | ||||
|     name: 'Home', | ||||
|     component: Home | ||||
|   }, | ||||
|   { | ||||
|     path: '/services/:key', | ||||
|     name: 'Details', | ||||
|     component: Details | ||||
|   } | ||||
| ] | ||||
|  | ||||
| const router = createRouter({ | ||||
|   history: createWebHistory(process.env.BASE_URL), | ||||
|   routes | ||||
| }) | ||||
|  | ||||
| export default router | ||||
							
								
								
									
										90
									
								
								web/app/src/views/Details.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								web/app/src/views/Details.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,90 @@ | ||||
| <template> | ||||
|   <router-link to="/" class="absolute top-2 left-2 inline-block px-2 py-0 text-lg font-medium leading-6 text-center text-black transition bg-gray-100 rounded shadow ripple hover:shadow-lg hover:bg-gray-200 focus:outline-none"> | ||||
|     ← | ||||
|   </router-link> | ||||
|   <div class="container mx-auto"> | ||||
|     <slot v-if="serviceStatus"> | ||||
|       <h1 class="text-3xl text-monospace text-gray-400">RECENT CHECKS</h1> | ||||
|       <hr class="mb-4" /> | ||||
|       <Service :data="serviceStatus" :maximumNumberOfResults="20" @showTooltip="showTooltip" /> | ||||
|     </slot> | ||||
|     <!-- print table of each results in table? that'd be sick as fuck --> | ||||
|  | ||||
|     <div v-if="serviceStatus.uptime" class="mt-5"> | ||||
|       <h1 class="text-3xl text-monospace text-gray-400">UPTIME</h1> | ||||
|       <hr /> | ||||
|       <div class="flex space-x-4 text-center text-2xl mt-5"> | ||||
|         <div class="flex-1"> | ||||
|           {{ prettifyUptime(serviceStatus.uptime['7d']) }} | ||||
|           <h2 class="text-sm text-gray-400">Last 7 days</h2> | ||||
|         </div> | ||||
|         <div class="flex-1"> | ||||
|           {{ prettifyUptime(serviceStatus.uptime['24h']) }} | ||||
|           <h2 class="text-sm text-gray-400">Last 24 hours</h2> | ||||
|         </div> | ||||
|         <div class="flex-1"> | ||||
|           {{ prettifyUptime(serviceStatus.uptime['1h']) }} | ||||
|           <h2 class="text-sm text-gray-400">Last hour</h2> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|   </div> | ||||
|   <Settings @refreshData="fetchData"/> | ||||
| </template> | ||||
|  | ||||
|  | ||||
| <script> | ||||
| import Settings from '@/components/Settings.vue' | ||||
| import Service from '@/components/Service.vue'; | ||||
| import {SERVER_URL} from "@/main.js"; | ||||
|  | ||||
| export default { | ||||
|   name: 'Details', | ||||
|   components: { | ||||
|     Service, | ||||
|     Settings, | ||||
|   }, | ||||
|   emits: ['showTooltip'], | ||||
|   methods: { | ||||
|     fetchData() { | ||||
|       console.log("[Details][fetchData] Fetching data"); | ||||
|       fetch(`${SERVER_URL}/api/v1/statuses/${this.$route.params.key}`) | ||||
|           .then(response => response.json()) | ||||
|           .then(data => { | ||||
|             if (JSON.stringify(this.serviceStatus) !== JSON.stringify(data)) { | ||||
|               console.log(data); | ||||
|               this.serviceStatus = data; | ||||
|             } | ||||
|           }); | ||||
|     }, | ||||
|     prettifyUptime(uptime) { | ||||
|       if (!uptime) { | ||||
|         return "0%"; | ||||
|       } | ||||
|       return (uptime * 100).toFixed(2) + "%" | ||||
|     }, | ||||
|     showTooltip(result, event) { | ||||
|       this.$emit('showTooltip', result, event); | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       serviceStatus: {} | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     this.fetchData(); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .service { | ||||
|   border-bottom-left-radius: 3px; | ||||
|   border-bottom-right-radius: 3px; | ||||
|   border-bottom-width: 1px; | ||||
|   border-color: #dee2e6; | ||||
|   border-style: solid; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										43
									
								
								web/app/src/views/Home.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								web/app/src/views/Home.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,43 @@ | ||||
| <template> | ||||
|   <Services :serviceStatuses="serviceStatuses" :showStatusOnHover="true" @showTooltip="showTooltip"/> | ||||
|   <Settings @refreshData="fetchData"/> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import Settings from '@/components/Settings.vue' | ||||
| import Services from '@/components/Services.vue'; | ||||
| import {SERVER_URL} from "@/main.js"; | ||||
|  | ||||
| export default { | ||||
|   name: 'Home', | ||||
|   components: { | ||||
|     Services, | ||||
|     Settings, | ||||
|   }, | ||||
|   emits: ['showTooltip'], | ||||
|   methods: { | ||||
|     fetchData() { | ||||
|       console.log("[Home][fetchData] Fetching data"); | ||||
|       fetch(`${SERVER_URL}/api/v1/statuses`) | ||||
|           .then(response => response.json()) | ||||
|           .then(data => { | ||||
|             if (JSON.stringify(this.serviceStatuses) !== JSON.stringify(data)) { | ||||
|               console.log(data); | ||||
|               this.serviceStatuses = data; | ||||
|             } | ||||
|           }); | ||||
|     }, | ||||
|     showTooltip(result, event) { | ||||
|       this.$emit('showTooltip', result, event); | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       serviceStatuses: {} | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     this.fetchData(); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
		Reference in New Issue
	
	Block a user