From 94eb3868e62f63b306d49ae13f5d6e29803d3c8e Mon Sep 17 00:00:00 2001 From: TwinProduction Date: Thu, 26 Nov 2020 18:09:01 -0500 Subject: [PATCH 1/5] Start working on #13: Service groups --- core/condition-result.go | 10 +++++ core/health-status.go | 11 ++++++ core/{types.go => result.go} | 21 +---------- core/service-status.go | 27 ++++++++++++++ core/service.go | 3 ++ main.go | 30 +++++++-------- static/index.html | 72 +++++++++++++++++++++++++++++------- watchdog/watchdog.go | 35 +++++++++++------- 8 files changed, 147 insertions(+), 62 deletions(-) create mode 100644 core/condition-result.go create mode 100644 core/health-status.go rename core/{types.go => result.go} (64%) create mode 100644 core/service-status.go diff --git a/core/condition-result.go b/core/condition-result.go new file mode 100644 index 00000000..d8bdc1e9 --- /dev/null +++ b/core/condition-result.go @@ -0,0 +1,10 @@ +package core + +// ConditionResult result of a Condition +type ConditionResult struct { + // Condition that was evaluated + Condition string `json:"condition"` + + // Success whether the condition was met (successful) or not (failed) + Success bool `json:"success"` +} diff --git a/core/health-status.go b/core/health-status.go new file mode 100644 index 00000000..89e906eb --- /dev/null +++ b/core/health-status.go @@ -0,0 +1,11 @@ +package core + +// HealthStatus is the status of Gatus +type HealthStatus struct { + // Status is the state of Gatus (UP/DOWN) + Status string `json:"status"` + + // Message is an accompanying description of why the status is as reported. + // If the Status is UP, no message will be provided + Message string `json:"message,omitempty"` +} diff --git a/core/types.go b/core/result.go similarity index 64% rename from core/types.go rename to core/result.go index fabca387..5c538c64 100644 --- a/core/types.go +++ b/core/result.go @@ -4,22 +4,12 @@ import ( "time" ) -// HealthStatus is the status of Gatus -type HealthStatus struct { - // Status is the state of Gatus (UP/DOWN) - Status string `json:"status"` - - // Message is an accompanying description of why the status is as reported. - // If the Status is UP, no message will be provided - Message string `json:"message,omitempty"` -} - // Result of the evaluation of a Service type Result struct { // HTTPStatus is the HTTP response status code HTTPStatus int `json:"status"` - // DNSRCode is the response code of DNS query in human readable version + // DNSRCode is the response code of a DNS query in a human readable format DNSRCode string `json:"dns-rcode"` // Body is the response body @@ -52,12 +42,3 @@ type Result struct { // CertificateExpiration is the duration before the certificate expires CertificateExpiration time.Duration `json:"certificate-expiration,omitempty"` } - -// ConditionResult result of a Condition -type ConditionResult struct { - // Condition that was evaluated - Condition string `json:"condition"` - - // Success whether the condition was met (successful) or not (failed) - Success bool `json:"success"` -} diff --git a/core/service-status.go b/core/service-status.go new file mode 100644 index 00000000..92102dd7 --- /dev/null +++ b/core/service-status.go @@ -0,0 +1,27 @@ +package core + +// ServiceStatus contains the evaluation Results of a Service +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"` + + // Results is the list of service evaluation results + Results []*Result `json:"results"` +} + +// NewServiceStatus creates a new ServiceStatus +func NewServiceStatus(service *Service) *ServiceStatus { + return &ServiceStatus{ + Group: service.Group, + Results: make([]*Result, 0), + } +} + +// AddResult adds a Result to ServiceStatus.Results and makes sure that there are +// no more than 20 results in the Results slice +func (ss *ServiceStatus) AddResult(result *Result) { + ss.Results = append(ss.Results, result) + if len(ss.Results) > 20 { + ss.Results = ss.Results[1:] + } +} diff --git a/core/service.go b/core/service.go index f735b2ca..928940cb 100644 --- a/core/service.go +++ b/core/service.go @@ -30,6 +30,9 @@ type Service struct { // Name of the service. Can be anything. Name string `yaml:"name"` + // Group the service is a part of. Used for grouping multiple services together on the front end. + Group string `yaml:"group,omitempty"` + // URL to send the request to URL string `yaml:"url"` diff --git a/main.go b/main.go index e3cfd931..e01a7ae4 100644 --- a/main.go +++ b/main.go @@ -18,19 +18,19 @@ import ( const cacheTTL = 10 * time.Second var ( - cachedServiceResults []byte - cachedServiceResultsGzipped []byte - cachedServiceResultsTimestamp time.Time + cachedServiceStatuses []byte + cachedServiceStatusesGzipped []byte + cachedServiceStatusesTimestamp time.Time ) func main() { cfg := loadConfiguration() - resultsHandler := serviceResultsHandler + statusesHandler := serviceStatusesHandler if cfg.Security != nil && cfg.Security.IsValid() { - resultsHandler = security.Handler(serviceResultsHandler, cfg.Security) + statusesHandler = security.Handler(serviceStatusesHandler, cfg.Security) } http.HandleFunc("/favicon.ico", favIconHandler) // favicon needs to be always served from the root - http.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/results"), resultsHandler) + http.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), statusesHandler) http.HandleFunc(cfg.Web.PrependWithContextRoot("/health"), healthHandler) http.Handle(cfg.Web.ContextRoot, GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./static"))))) @@ -56,29 +56,29 @@ func loadConfiguration() *config.Config { return config.Get() } -func serviceResultsHandler(writer http.ResponseWriter, r *http.Request) { - if isExpired := cachedServiceResultsTimestamp.IsZero() || time.Now().Sub(cachedServiceResultsTimestamp) > cacheTTL; isExpired { +func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) { + if isExpired := cachedServiceStatusesTimestamp.IsZero() || time.Now().Sub(cachedServiceStatusesTimestamp) > cacheTTL; isExpired { buffer := &bytes.Buffer{} gzipWriter := gzip.NewWriter(buffer) - data, err := watchdog.GetJSONEncodedServiceResults() + data, err := watchdog.GetJSONEncodedServiceStatuses() if err != nil { - log.Printf("[main][serviceResultsHandler] Unable to marshal object to JSON: %s", err.Error()) + log.Printf("[main][serviceStatusesHandler] Unable to marshal object to JSON: %s", err.Error()) writer.WriteHeader(http.StatusInternalServerError) _, _ = writer.Write([]byte("Unable to marshal object to JSON")) return } gzipWriter.Write(data) gzipWriter.Close() - cachedServiceResults = data - cachedServiceResultsGzipped = buffer.Bytes() - cachedServiceResultsTimestamp = time.Now() + cachedServiceStatuses = data + cachedServiceStatusesGzipped = buffer.Bytes() + cachedServiceStatusesTimestamp = time.Now() } var data []byte if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { writer.Header().Set("Content-Encoding", "gzip") - data = cachedServiceResultsGzipped + data = cachedServiceStatusesGzipped } else { - data = cachedServiceResults + data = cachedServiceStatuses } writer.Header().Add("Content-type", "application/json") writer.WriteHeader(http.StatusOK) diff --git a/static/index.html b/static/index.html index 7ada745a..ac18811d 100644 --- a/static/index.html +++ b/static/index.html @@ -99,6 +99,13 @@ #settings select:focus { box-shadow: none; } + .service-group { + cursor: pointer; + user-select: none; + } + .service-group h5:hover { + color: #1b1e21 !important; + } @@ -162,7 +169,7 @@ function showTooltip(serviceName, index, element) { userClickedStatus = false; clearTimeout(timerHandler); - let serviceResult = serviceStatuses[serviceName][index]; + let serviceResult = serviceStatuses[serviceName].results[index]; $("#tooltip-timestamp").text(prettifyTimestamp(serviceResult.timestamp)); $("#tooltip-response-time").text(parseInt(serviceResult.duration/1000000) + "ms"); // Populate the condition section @@ -219,8 +226,8 @@ return "X"; } - function refreshResults() { - $.getJSON("./api/v1/results", function (data) { + function refreshStatuses() { + $.getJSON("./api/v1/statuses", function (data) { // Update the table only if there's a change if (JSON.stringify(serviceStatuses) !== JSON.stringify(data)) { serviceStatuses = data; @@ -230,16 +237,17 @@ } function buildTable() { - let output = ""; + let outputByGroup = {}; for (let serviceName in serviceStatuses) { let serviceStatusOverTime = ""; - let hostname = serviceStatuses[serviceName][serviceStatuses[serviceName].length-1].hostname + let serviceStatus = serviceStatuses[serviceName]; + let hostname = serviceStatus.results[serviceStatus.results.length-1].hostname; let minResponseTime = null; let maxResponseTime = null; let newestTimestamp = null; let oldestTimestamp = null; - for (let key in serviceStatuses[serviceName]) { - let serviceResult = serviceStatuses[serviceName][key]; + for (let key in serviceStatus.results) { + let serviceResult = serviceStatus.results[key]; serviceStatusOverTime = createStatusBadge(serviceName, key, serviceResult.success) + serviceStatusOverTime; const responseTime = parseInt(serviceResult.duration/1000000); if (minResponseTime == null || minResponseTime > responseTime) { @@ -256,8 +264,8 @@ oldestTimestamp = timestamp; } } - output += "" - + "
" + let output = "" + + "
" + "
" + "
" + " " + serviceName + " - " + hostname + "" @@ -280,10 +288,48 @@ + "
" + "
" + "
"; + // create an empty entry if this group is new + if (!outputByGroup[serviceStatus.group]) { + outputByGroup[serviceStatus.group] = ""; + } + outputByGroup[serviceStatus.group] += output; + } + let output = ""; + for (let group in outputByGroup) { + let key = group.replace(/[^a-zA-Z0-9]/g, ''); + let existingGroupContentSelector = $("#service-group-" + key + "-content"); + let isCurrentlyHidden = existingGroupContentSelector.length && existingGroupContentSelector[0].style.display === 'none'; + let groupStatus = ""; + if (outputByGroup[group].includes("badge badge-danger")) { + groupStatus = "~"; + } + output += "" + + "
" + + "
" + + "
" + + " " + groupStatus + " " + group + + " " + (isCurrentlyHidden ? "▼" : "▲") + "" + + "
" + + "
" + + "
" + + " " + outputByGroup[group] + + "
" + + "
"; } $("#results").html(output); } + function toggleGroup(element) { + let selector = $("#service-group-" + element.dataset.group + "-content"); + selector.toggle("fast", function() { + if (selector.length && selector[0].style.display === 'none') { + $("#service-group-" + element.dataset.group + "-arrow").html("▼"); + } else { + $("#service-group-" + element.dataset.group + "-arrow").html("▲"); + } + }); + } + function prettifyTimestamp(timestamp) { let date = new Date(timestamp); let YYYY = date.getFullYear(); @@ -318,15 +364,15 @@ } function setRefreshInterval(seconds) { - refreshResults(); + refreshStatuses(); refreshIntervalHandler = setInterval(function() { - refreshResults(); - }, seconds * 1000) + refreshStatuses(); + }, seconds * 1000); } $("#refresh-rate").change(function() { clearInterval(refreshIntervalHandler); - setRefreshInterval($(this).val()) + setRefreshInterval($(this).val()); }); setRefreshInterval(30); $("#refresh-rate").val(30); diff --git a/watchdog/watchdog.go b/watchdog/watchdog.go index e0151675..281b534b 100644 --- a/watchdog/watchdog.go +++ b/watchdog/watchdog.go @@ -13,22 +13,22 @@ import ( ) var ( - serviceResults = make(map[string][]*core.Result) + serviceStatuses = make(map[string]*core.ServiceStatus) - // serviceResultsMutex is used to prevent concurrent map access - serviceResultsMutex sync.RWMutex + // serviceStatusesMutex is used to prevent concurrent map access + serviceStatusesMutex sync.RWMutex // monitoringMutex is used to prevent multiple services from being evaluated at the same time. // Without this, conditions using response time may become inaccurate. monitoringMutex sync.Mutex ) -// GetJSONEncodedServiceResults returns a list of the last 20 results for each services encoded using json.Marshal. +// GetJSONEncodedServiceStatuses returns a list of core.ServiceStatus for each services encoded using json.Marshal. // The reason why the encoding is done here is because we use a mutex to prevent concurrent map access. -func GetJSONEncodedServiceResults() ([]byte, error) { - serviceResultsMutex.RLock() - data, err := json.Marshal(serviceResults) - serviceResultsMutex.RUnlock() +func GetJSONEncodedServiceStatuses() ([]byte, error) { + serviceStatusesMutex.RLock() + data, err := json.Marshal(serviceStatuses) + serviceStatusesMutex.RUnlock() return data, err } @@ -55,12 +55,7 @@ func monitor(service *core.Service) { } result := service.EvaluateHealth() metric.PublishMetricsForService(service, result) - serviceResultsMutex.Lock() - serviceResults[service.Name] = append(serviceResults[service.Name], result) - if len(serviceResults[service.Name]) > 20 { - serviceResults[service.Name] = serviceResults[service.Name][1:] - } - serviceResultsMutex.Unlock() + UpdateServiceStatuses(service, result) var extra string if !result.Success { extra = fmt.Sprintf("responseBody=%s", result.Body) @@ -83,3 +78,15 @@ func monitor(service *core.Service) { time.Sleep(service.Interval) } } + +// UpdateServiceStatuses updates the slice of service statuses +func UpdateServiceStatuses(service *core.Service, result *core.Result) { + serviceStatusesMutex.Lock() + serviceStatus, exists := serviceStatuses[service.Name] + if !exists { + serviceStatus = core.NewServiceStatus(service) + serviceStatuses[service.Name] = serviceStatus + } + serviceStatus.AddResult(result) + serviceStatusesMutex.Unlock() +} From 35b9758b23fbbedbb4cad13f83550cf86f543a56 Mon Sep 17 00:00:00 2001 From: TwinProduction Date: Thu, 26 Nov 2020 18:29:11 -0500 Subject: [PATCH 2/5] Add non-grouped services at the bottom of the dashboard --- static/index.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/static/index.html b/static/index.html index ac18811d..a80e0081 100644 --- a/static/index.html +++ b/static/index.html @@ -296,6 +296,10 @@ } let output = ""; for (let group in outputByGroup) { + // Services that don't have a group should be skipped and left for last + if (group === 'undefined') { + continue + } let key = group.replace(/[^a-zA-Z0-9]/g, ''); let existingGroupContentSelector = $("#service-group-" + key + "-content"); let isCurrentlyHidden = existingGroupContentSelector.length && existingGroupContentSelector[0].style.display === 'none'; @@ -316,6 +320,13 @@ + "
" + ""; } + // Add all services that don't have a group at the end + if (outputByGroup['undefined']) { + output += "" + + "
" + + " " + outputByGroup['undefined'] + + "
" + } $("#results").html(output); } From da4fb10bfcd3d915b23f7edcc17fd32bd192ddfd Mon Sep 17 00:00:00 2001 From: TwinProduction Date: Thu, 26 Nov 2020 23:23:51 -0500 Subject: [PATCH 3/5] Add documentation for groups --- .github/assets/service-groups.png | Bin 0 -> 39559 bytes README.md | 48 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .github/assets/service-groups.png diff --git a/.github/assets/service-groups.png b/.github/assets/service-groups.png new file mode 100644 index 0000000000000000000000000000000000000000..25d34ce0dbbc18d5b01139d6cc24e5d383d87e71 GIT binary patch literal 39559 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 54398b40..39529ab8 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ core applications: https://status.twinnation.org/ - [Monitoring using DNS queries](#monitoring-using-dns-queries) - [Basic authentication](#basic-authentication) - [disable-monitoring-lock](#disable-monitoring-lock) + - [Service groups](#service-groups) ## Features @@ -97,6 +98,7 @@ Note that you can also add environment variables in the configuration file (i.e. | `metrics` | Whether to expose metrics at /metrics | `false` | | `services` | List of services to monitor | Required `[]` | | `services[].name` | Name of the service. Can be anything. | Required `""` | +| `services[].group` | Group name. Used to group multiple services together on the dashboard. See [Service groups](#service-groups). | `""` | | `services[].url` | URL to send the request to | Required `""` | | `services[].method` | Request method | `GET` | | `services[].insecure` | Whether to skip verifying the server's certificate chain and host name | `false` | @@ -614,3 +616,49 @@ There are three main reasons why you might want to disable the monitoring lock: technically, if you create 100 services with a 1 seconds interval, Gatus will send 100 requests per second) - You have a _lot_ of services to monitor - You want to test multiple services at very short interval (< 5s) + + +### Service groups + +Service groups are used for grouping multiple services together on the dashboard. + +```yaml +services: + - name: frontend + group: core + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + + - name: backend + group: core + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + + - name: monitoring + group: internal + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + + - name: nas + group: internal + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + + - name: random service that isn't part of a group + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" +``` + +The configuration above will result in a dashboard that looks like this: + +![Gatus Service Groups](.github/assets/service-groups.png) \ No newline at end of file From 6a08c816e56bab73baddbafca9f169f69f51291a Mon Sep 17 00:00:00 2001 From: TwinProduction Date: Thu, 26 Nov 2020 23:23:59 -0500 Subject: [PATCH 4/5] Update default configuration --- config.yaml | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/config.yaml b/config.yaml index e14b1006..0c09a6e1 100644 --- a/config.yaml +++ b/config.yaml @@ -1,15 +1,37 @@ -metrics: true services: - - name: twinnation + - name: frontend + group: core url: "https://twinnation.org/health" - interval: 30s + interval: 1m conditions: - "[STATUS] == 200" - "[BODY].status == UP" - "[RESPONSE_TIME] < 1000" + + - name: backend + group: core + url: "http://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + + - name: monitoring + group: internal + url: "http://example.com/" + interval: 5m + conditions: + - "[STATUS] == 200" + + - name: nas + group: internal + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + - name: cat-fact url: "https://cat-fact.herokuapp.com/facts/random" - interval: 1m + interval: 5m conditions: - "[STATUS] == 200" - "[BODY].deleted == false" From 9c8bfcd19f1da48408c9b0d487522120b65a2484 Mon Sep 17 00:00:00 2001 From: TwinProduction Date: Thu, 26 Nov 2020 23:45:17 -0500 Subject: [PATCH 5/5] Add service-status_test.go --- core/service-status_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 core/service-status_test.go diff --git a/core/service-status_test.go b/core/service-status_test.go new file mode 100644 index 00000000..4e833dfd --- /dev/null +++ b/core/service-status_test.go @@ -0,0 +1,22 @@ +package core + +import "testing" + +func TestNewServiceStatus(t *testing.T) { + service := &Service{Group: "test"} + serviceStatus := NewServiceStatus(service) + if serviceStatus.Group != service.Group { + t.Errorf("expected %s, got %s", service.Group, serviceStatus.Group) + } +} + +func TestServiceStatus_AddResult(t *testing.T) { + service := &Service{Group: "test"} + serviceStatus := NewServiceStatus(service) + for i := 0; i < 50; i++ { + serviceStatus.AddResult(&Result{}) + } + if len(serviceStatus.Results) != 20 { + t.Errorf("expected serviceStatus.Results to not exceed a length of 20") + } +}