Add page for individual service details
This commit is contained in:
		| @ -3,6 +3,7 @@ package controller | |||||||
| import ( | import ( | ||||||
| 	"bytes" | 	"bytes" | ||||||
| 	"compress/gzip" | 	"compress/gzip" | ||||||
|  | 	"encoding/json" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"log" | 	"log" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| @ -53,12 +54,9 @@ func Handle() { | |||||||
| // CreateRouter creates the router for the http server | // CreateRouter creates the router for the http server | ||||||
| func CreateRouter(cfg *config.Config) *mux.Router { | func CreateRouter(cfg *config.Config) *mux.Router { | ||||||
| 	router := mux.NewRouter() | 	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("/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("/api/v1/badges/uptime/{duration}/{identifier}"), badgeHandler).Methods("GET") | ||||||
| 	router.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler).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"))))) | 	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 | 	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) { | func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) { | ||||||
| 	gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") | 	gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") | ||||||
| 	var exists bool | 	var exists bool | ||||||
| @ -85,7 +93,7 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) { | |||||||
| 		gzipWriter := gzip.NewWriter(buffer) | 		gzipWriter := gzip.NewWriter(buffer) | ||||||
| 		data, err = watchdog.GetServiceStatusesAsJSON() | 		data, err = watchdog.GetServiceStatusesAsJSON() | ||||||
| 		if err != nil { | 		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.WriteHeader(http.StatusInternalServerError) | ||||||
| 			_, _ = writer.Write([]byte("Unable to marshal object to JSON")) | 			_, _ = writer.Write([]byte("Unable to marshal object to JSON")) | ||||||
| 			return | 			return | ||||||
| @ -106,6 +114,28 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) { | |||||||
| 	_, _ = writer.Write(data) | 	_, _ = 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) { | func healthHandler(writer http.ResponseWriter, _ *http.Request) { | ||||||
| 	writer.Header().Add("Content-Type", "application/json") | 	writer.Header().Add("Content-Type", "application/json") | ||||||
| 	writer.WriteHeader(http.StatusOK) | 	writer.WriteHeader(http.StatusOK) | ||||||
|  | |||||||
| @ -32,10 +32,18 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) { | |||||||
| 	return w.Writer.Write(b) | 	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 | // supports gzip encoding | ||||||
| func GzipHandler(next http.Handler) http.Handler { | 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 the request doesn't specify that it supports gzip, then don't compress it | ||||||
| 		if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { | 		if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { | ||||||
| 			next.ServeHTTP(writer, r) | 			next.ServeHTTP(writer, r) | ||||||
| @ -47,5 +55,5 @@ func GzipHandler(next http.Handler) http.Handler { | |||||||
| 		gz.Reset(writer) | 		gz.Reset(writer) | ||||||
| 		defer gz.Close() | 		defer gz.Close() | ||||||
| 		next.ServeHTTP(&gzipResponseWriter{ResponseWriter: writer, Writer: gz}, r) | 		next.ServeHTTP(&gzipResponseWriter{ResponseWriter: writer, Writer: gz}, r) | ||||||
| 	}) | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,5 +1,7 @@ | |||||||
| package core | package core | ||||||
|  |  | ||||||
|  | import "github.com/TwinProduction/gatus/util" | ||||||
|  |  | ||||||
| // ServiceStatus contains the evaluation Results of a Service | // ServiceStatus contains the evaluation Results of a Service | ||||||
| type ServiceStatus struct { | type ServiceStatus struct { | ||||||
| 	// Name of the service | 	// 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 the service is a part of. Used for grouping multiple services together on the front end. | ||||||
| 	Group string `json:"group,omitempty"` | 	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 is the list of service evaluation results | ||||||
| 	Results []*Result `json:"results"` | 	Results []*Result `json:"results"` | ||||||
|  |  | ||||||
| @ -20,6 +25,7 @@ func NewServiceStatus(service *Service) *ServiceStatus { | |||||||
| 	return &ServiceStatus{ | 	return &ServiceStatus{ | ||||||
| 		Name:    service.Name, | 		Name:    service.Name, | ||||||
| 		Group:   service.Group, | 		Group:   service.Group, | ||||||
|  | 		Key:     util.ConvertGroupAndServiceToKey(service.Group, service.Name), | ||||||
| 		Results: make([]*Result, 0), | 		Results: make([]*Result, 0), | ||||||
| 		Uptime:  NewUptime(), | 		Uptime:  NewUptime(), | ||||||
| 	} | 	} | ||||||
|  | |||||||
| @ -14,6 +14,9 @@ func TestNewServiceStatus(t *testing.T) { | |||||||
| 	if serviceStatus.Group != service.Group { | 	if serviceStatus.Group != service.Group { | ||||||
| 		t.Errorf("expected %s, got %s", service.Group, serviceStatus.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) { | func TestServiceStatus_AddResult(t *testing.T) { | ||||||
|  | |||||||
| @ -2,10 +2,10 @@ package storage | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"fmt" |  | ||||||
| 	"sync" | 	"sync" | ||||||
|  |  | ||||||
| 	"github.com/TwinProduction/gatus/core" | 	"github.com/TwinProduction/gatus/core" | ||||||
|  | 	"github.com/TwinProduction/gatus/util" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // InMemoryStore implements an in-memory store | // 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 | // GetServiceStatus returns the service status for a given service name in the given group | ||||||
| func (ims *InMemoryStore) GetServiceStatus(group, name string) *core.ServiceStatus { | func (ims *InMemoryStore) GetServiceStatus(groupName, serviceName string) *core.ServiceStatus { | ||||||
| 	key := fmt.Sprintf("%s_%s", group, name) | 	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() | 	ims.serviceResultsMutex.RLock() | ||||||
| 	serviceStatus := ims.serviceStatuses[key] | 	serviceStatus := ims.serviceStatuses[key] | ||||||
| 	ims.serviceResultsMutex.RUnlock() | 	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 | // Insert inserts the observed result for the specified service into the in memory store | ||||||
| func (ims *InMemoryStore) Insert(service *core.Service, result *core.Result) { | 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() | 	ims.serviceResultsMutex.Lock() | ||||||
| 	serviceStatus, exists := ims.serviceStatuses[key] | 	serviceStatus, exists := ims.serviceStatuses[key] | ||||||
| 	if !exists { | 	if !exists { | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ import ( | |||||||
| 	"time" | 	"time" | ||||||
|  |  | ||||||
| 	"github.com/TwinProduction/gatus/core" | 	"github.com/TwinProduction/gatus/core" | ||||||
|  | 	"github.com/TwinProduction/gatus/util" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var ( | var ( | ||||||
| @ -160,7 +161,6 @@ func TestInMemoryStore_GetServiceStatus(t *testing.T) { | |||||||
| 	if serviceStatus.Uptime.LastSevenDays != 0.5 { | 	if serviceStatus.Uptime.LastSevenDays != 0.5 { | ||||||
| 		t.Errorf("serviceStatus.Uptime.LastSevenDays should've been 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) { | 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) { | func TestInMemoryStore_GetAllAsJSON(t *testing.T) { | ||||||
| 	store := NewInMemoryStore() | 	store := NewInMemoryStore() | ||||||
| 	firstResult := &testSuccessfulResult | 	firstResult := &testSuccessfulResult | ||||||
| @ -194,7 +217,7 @@ func TestInMemoryStore_GetAllAsJSON(t *testing.T) { | |||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		t.Fatal("shouldn't have returned an error, got", err.Error()) | 		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 { | 	if string(output) != expectedOutput { | ||||||
| 		t.Errorf("expected:\n %s\n\ngot:\n %s", expectedOutput, string(output)) | 		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() | 	return store.GetAllAsJSON() | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetUptimeByServiceGroupAndName returns the uptime of a service based on its group and name | // GetUptimeByKey returns the uptime of a service based on the ServiceStatus key | ||||||
| func GetUptimeByServiceGroupAndName(group, name string) *core.Uptime { | func GetUptimeByKey(key string) *core.Uptime { | ||||||
| 	serviceStatus := store.GetServiceStatus(group, name) | 	serviceStatus := store.GetServiceStatusByKey(key) | ||||||
| 	if serviceStatus == nil { | 	if serviceStatus == nil { | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
| 	return serviceStatus.Uptime | 	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 | // Monitor loops over each services and starts a goroutine to monitor each services separately | ||||||
| func Monitor(cfg *config.Config) { | func Monitor(cfg *config.Config) { | ||||||
| 	for _, service := range cfg.Services { | 	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", |         "core-js": "^3.6.5", | ||||||
|         "postcss": "^7.0.35", |         "postcss": "^7.0.35", | ||||||
|         "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2", |         "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2", | ||||||
|         "vue": "^3.0.0" |         "vue": "^3.0.0", | ||||||
|  |         "vue-router": "^4.0.0-0" | ||||||
|       }, |       }, | ||||||
|       "devDependencies": { |       "devDependencies": { | ||||||
|         "@vue/cli-plugin-babel": "~4.5.0", |         "@vue/cli-plugin-babel": "~4.5.0", | ||||||
|         "@vue/cli-plugin-eslint": "~4.5.0", |         "@vue/cli-plugin-eslint": "~4.5.0", | ||||||
|  |         "@vue/cli-plugin-router": "~4.5.0", | ||||||
|         "@vue/cli-service": "~4.5.0", |         "@vue/cli-service": "~4.5.0", | ||||||
|         "@vue/compiler-sfc": "^3.0.0", |         "@vue/compiler-sfc": "^3.0.0", | ||||||
|         "babel-eslint": "^10.1.0", |         "babel-eslint": "^10.1.0", | ||||||
| @ -14086,6 +14088,14 @@ | |||||||
|       "integrity": "sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ=", |       "integrity": "sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ=", | ||||||
|       "dev": true |       "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": { |     "node_modules/vue-style-loader": { | ||||||
|       "version": "4.1.2", |       "version": "4.1.2", | ||||||
|       "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz", |       "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz", | ||||||
| @ -17085,8 +17095,7 @@ | |||||||
|       "version": "4.5.11", |       "version": "4.5.11", | ||||||
|       "resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.11.tgz", |       "resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.11.tgz", | ||||||
|       "integrity": "sha512-JBPeZLubiSHbRkEKDj0tnLiU43AJ3vt6JULn4IKWH1XWZ6MFC8vElaP5/AA4O3Zko5caamDDBq3TRyxdA2ncUQ==", |       "integrity": "sha512-JBPeZLubiSHbRkEKDj0tnLiU43AJ3vt6JULn4IKWH1XWZ6MFC8vElaP5/AA4O3Zko5caamDDBq3TRyxdA2ncUQ==", | ||||||
|       "dev": true, |       "dev": true | ||||||
|       "requires": {} |  | ||||||
|     }, |     }, | ||||||
|     "@vue/cli-service": { |     "@vue/cli-service": { | ||||||
|       "version": "4.5.11", |       "version": "4.5.11", | ||||||
| @ -17325,8 +17334,7 @@ | |||||||
|       "version": "1.1.2", |       "version": "1.1.2", | ||||||
|       "resolved": "https://registry.npmjs.org/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz", |       "resolved": "https://registry.npmjs.org/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz", | ||||||
|       "integrity": "sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==", |       "integrity": "sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==", | ||||||
|       "dev": true, |       "dev": true | ||||||
|       "requires": {} |  | ||||||
|     }, |     }, | ||||||
|     "@vue/reactivity": { |     "@vue/reactivity": { | ||||||
|       "version": "3.0.5", |       "version": "3.0.5", | ||||||
| @ -17572,8 +17580,7 @@ | |||||||
|       "version": "5.3.1", |       "version": "5.3.1", | ||||||
|       "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", |       "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", | ||||||
|       "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", |       "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", | ||||||
|       "dev": true, |       "dev": true | ||||||
|       "requires": {} |  | ||||||
|     }, |     }, | ||||||
|     "acorn-node": { |     "acorn-node": { | ||||||
|       "version": "1.8.2", |       "version": "1.8.2", | ||||||
| @ -17622,15 +17629,13 @@ | |||||||
|       "version": "1.0.1", |       "version": "1.0.1", | ||||||
|       "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", |       "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", | ||||||
|       "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", |       "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", | ||||||
|       "dev": true, |       "dev": true | ||||||
|       "requires": {} |  | ||||||
|     }, |     }, | ||||||
|     "ajv-keywords": { |     "ajv-keywords": { | ||||||
|       "version": "3.5.2", |       "version": "3.5.2", | ||||||
|       "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", |       "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", | ||||||
|       "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", |       "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", | ||||||
|       "dev": true, |       "dev": true | ||||||
|       "requires": {} |  | ||||||
|     }, |     }, | ||||||
|     "alphanum-sort": { |     "alphanum-sort": { | ||||||
|       "version": "1.0.2", |       "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": { |     "vue-style-loader": { | ||||||
|       "version": "4.1.2", |       "version": "4.1.2", | ||||||
|       "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz", |       "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz", | ||||||
|  | |||||||
| @ -13,11 +13,13 @@ | |||||||
|     "core-js": "^3.6.5", |     "core-js": "^3.6.5", | ||||||
|     "postcss": "^7.0.35", |     "postcss": "^7.0.35", | ||||||
|     "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2", |     "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2", | ||||||
|     "vue": "^3.0.0" |     "vue": "^3.0.0", | ||||||
|  |     "vue-router": "^4.0.0-0" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@vue/cli-plugin-babel": "~4.5.0", |     "@vue/cli-plugin-babel": "~4.5.0", | ||||||
|     "@vue/cli-plugin-eslint": "~4.5.0", |     "@vue/cli-plugin-eslint": "~4.5.0", | ||||||
|  |     "@vue/cli-plugin-router": "~4.5.0", | ||||||
|     "@vue/cli-service": "~4.5.0", |     "@vue/cli-service": "~4.5.0", | ||||||
|     "@vue/compiler-sfc": "^3.0.0", |     "@vue/compiler-sfc": "^3.0.0", | ||||||
|     "babel-eslint": "^10.1.0", |     "babel-eslint": "^10.1.0", | ||||||
|  | |||||||
| @ -1,51 +1,42 @@ | |||||||
| <template> | <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"/> |   <Tooltip :result="tooltip.result" :event="tooltip.event"/> | ||||||
|   <Social/> |   <Social/> | ||||||
|   <Settings @refreshStatuses="fetchStatuses"/> |  | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
| import Social from './components/Social.vue' | 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 Tooltip from './components/Tooltip.vue'; | ||||||
| import {SERVER_URL} from "./main.js"; |  | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   name: 'App', |   name: 'App', | ||||||
|   components: { |   components: { | ||||||
|     Services, |  | ||||||
|     Social, |     Social, | ||||||
|     Settings, |  | ||||||
|     Tooltip |     Tooltip | ||||||
|   }, |   }, | ||||||
|   methods: { |   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) { |     showTooltip(result, event) { | ||||||
|       this.tooltip = {result: result, event: event}; |       this.tooltip = {result: result, event: event}; | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|       serviceStatuses: {}, |  | ||||||
|       tooltip: {} |       tooltip: {} | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   created() { |  | ||||||
|     this.fetchStatuses(); |  | ||||||
|   } |  | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @ -53,9 +44,10 @@ export default { | |||||||
| <style> | <style> | ||||||
| html, body { | html, body { | ||||||
|   background-color: #f7f9fb; |   background-color: #f7f9fb; | ||||||
| } |  | ||||||
|  |  | ||||||
| html, body { |  | ||||||
|   height: 100%; |   height: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #global, #results { | ||||||
|  |   max-width: 1200px; | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|  | |||||||
| @ -1,8 +1,8 @@ | |||||||
| <template> | <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='flex flex-wrap mb-2'> | ||||||
|       <div class='w-3/4'> |       <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> | ||||||
|       <div class='w-1/4 text-right'> |       <div class='w-1/4 text-right'> | ||||||
|         <span class='font-light status-min-max-ms'> |         <span class='font-light status-min-max-ms'> | ||||||
| @ -41,6 +41,7 @@ export default { | |||||||
|     maximumNumberOfResults: Number, |     maximumNumberOfResults: Number, | ||||||
|     data: Object, |     data: Object, | ||||||
|   }, |   }, | ||||||
|  |   emits: ['showTooltip'], | ||||||
|   methods: { |   methods: { | ||||||
|     updateMinAndMaxResponseTimes() { |     updateMinAndMaxResponseTimes() { | ||||||
|       let minResponseTime = null; |       let minResponseTime = null; | ||||||
| @ -73,6 +74,12 @@ export default { | |||||||
|       } |       } | ||||||
|       return (differenceInMs/1000).toFixed(0) + " seconds ago"; |       return (differenceInMs/1000).toFixed(0) + " seconds ago"; | ||||||
|     }, |     }, | ||||||
|  |     generatePath() { | ||||||
|  |       if (!this.data) { | ||||||
|  |         return "/"; | ||||||
|  |       } | ||||||
|  |       return "/services/" + this.data.key; | ||||||
|  |     }, | ||||||
|     showTooltip(result, event) { |     showTooltip(result, event) { | ||||||
|       this.$emit('showTooltip', result, event); |       this.$emit('showTooltip', result, event); | ||||||
|     } |     } | ||||||
| @ -96,6 +103,19 @@ export default { | |||||||
|  |  | ||||||
|  |  | ||||||
| <style> | <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 { | .status { | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   transition: all 500ms ease-in-out; |   transition: all 500ms ease-in-out; | ||||||
|  | |||||||
| @ -33,6 +33,7 @@ export default { | |||||||
|     name: String, |     name: String, | ||||||
|     services: Array |     services: Array | ||||||
|   }, |   }, | ||||||
|  |   emits: ['showTooltip'], | ||||||
|   methods: { |   methods: { | ||||||
|     healthCheck() { |     healthCheck() { | ||||||
|       if (this.services) { |       if (this.services) { | ||||||
|  | |||||||
| @ -1,20 +1,8 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="container mx-auto rounded shadow-xl border my-3 p-5 text-left" id="global"> |   <div id="results"> | ||||||
|     <div class="mb-2"> |     <slot v-for="serviceGroup in serviceGroups" :key="serviceGroup"> | ||||||
|       <div class="flex flex-wrap"> |       <ServiceGroup :services="serviceGroup.services" :name="serviceGroup.name" @showTooltip="showTooltip"/> | ||||||
|         <div class="w-2/3 text-left my-auto"> |     </slot> | ||||||
|           <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> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| @ -31,6 +19,7 @@ export default { | |||||||
|     showStatusOnHover: Boolean, |     showStatusOnHover: Boolean, | ||||||
|     serviceStatuses: Object |     serviceStatuses: Object | ||||||
|   }, |   }, | ||||||
|  |   emits: ['showTooltip'], | ||||||
|   methods: { |   methods: { | ||||||
|     process() { |     process() { | ||||||
|       let outputByGroup = {}; |       let outputByGroup = {}; | ||||||
| @ -45,7 +34,7 @@ export default { | |||||||
|       let serviceGroups = []; |       let serviceGroups = []; | ||||||
|       for (let name in outputByGroup) { |       for (let name in outputByGroup) { | ||||||
|         if (name !== 'undefined') { |         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 |       // Add all services that don't have a group at the end | ||||||
| @ -74,29 +63,8 @@ export default { | |||||||
|  |  | ||||||
|  |  | ||||||
| <style> | <style> | ||||||
| #global { | .service-group-content > div:nth-child(1) { | ||||||
|   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) { |  | ||||||
|   border-top-left-radius: 0; |   border-top-left-radius: 0; | ||||||
|   border-top-right-radius: 0; |   border-top-right-radius: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
| .title { |  | ||||||
|   font-size: 2.5rem; |  | ||||||
| } |  | ||||||
| </style> | </style> | ||||||
|  | |||||||
| @ -25,14 +25,14 @@ export default { | |||||||
|     setRefreshInterval(seconds) { |     setRefreshInterval(seconds) { | ||||||
|       let that = this; |       let that = this; | ||||||
|       this.refreshIntervalHandler = setInterval(function() { |       this.refreshIntervalHandler = setInterval(function() { | ||||||
|         that.refreshStatuses(); |         that.refreshData(); | ||||||
|       }, seconds * 1000); |       }, seconds * 1000); | ||||||
|     }, |     }, | ||||||
|     refreshStatuses() { |     refreshData() { | ||||||
|       this.$emit('refreshStatuses'); |       this.$emit('refreshData'); | ||||||
|     }, |     }, | ||||||
|     handleChangeRefreshInterval() { |     handleChangeRefreshInterval() { | ||||||
|       this.refreshStatuses(); |       this.refreshData(); | ||||||
|       clearInterval(this.refreshIntervalHandler); |       clearInterval(this.refreshIntervalHandler); | ||||||
|       this.setRefreshInterval(this.$refs.refreshInterval.value); |       this.setRefreshInterval(this.$refs.refreshInterval.value); | ||||||
|     } |     } | ||||||
| @ -40,6 +40,9 @@ export default { | |||||||
|   created() { |   created() { | ||||||
|     this.setRefreshInterval(this.refreshInterval); |     this.setRefreshInterval(this.refreshInterval); | ||||||
|   }, |   }, | ||||||
|  |   unmounted() { | ||||||
|  |     clearInterval(this.refreshIntervalHandler); | ||||||
|  |   }, | ||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|       refreshInterval: 30, |       refreshInterval: 30, | ||||||
|  | |||||||
| @ -1,7 +1,8 @@ | |||||||
| import { createApp } from 'vue' | import { createApp } from 'vue' | ||||||
| import App from './App.vue' | import App from './App.vue' | ||||||
| import './index.css' | import './index.css' | ||||||
|  | import router from './router' | ||||||
|  |  | ||||||
| export const SERVER_URL = process.env.NODE_ENV === 'production' ? '.' : 'http://localhost:8080' | 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