mirror of
https://github.com/TwiN/gatus.git
synced 2024-12-22 14:41:01 +01:00
Start working on #13: Service groups
This commit is contained in:
parent
68f32b0fcc
commit
94eb3868e6
10
core/condition-result.go
Normal file
10
core/condition-result.go
Normal file
@ -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"`
|
||||
}
|
11
core/health-status.go
Normal file
11
core/health-status.go
Normal file
@ -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"`
|
||||
}
|
@ -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"`
|
||||
}
|
27
core/service-status.go
Normal file
27
core/service-status.go
Normal file
@ -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:]
|
||||
}
|
||||
}
|
@ -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"`
|
||||
|
||||
|
30
main.go
30
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)
|
||||
|
@ -99,6 +99,13 @@
|
||||
#settings select:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
.service-group {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.service-group h5:hover {
|
||||
color: #1b1e21 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -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 "<span class='status badge badge-danger' style='width: 5%' onmouseenter='showTooltip(\""+serviceName+"\", "+index+", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>X</span>";
|
||||
}
|
||||
|
||||
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 += ""
|
||||
+ "<div class='container py-3 border-left border-right border-top border-black'>"
|
||||
let output = ""
|
||||
+ "<div class='container py-3 border-left border-right border-top border-black rounded-0'>"
|
||||
+ " <div class='row mb-2'>"
|
||||
+ " <div class='col-md-10'>"
|
||||
+ " <span class='font-weight-bold'>" + serviceName + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
|
||||
@ -280,10 +288,48 @@
|
||||
+ " </div>"
|
||||
+ " </div>"
|
||||
+ "</div>";
|
||||
// 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 = "<span class='text-success'>✓</span>";
|
||||
if (outputByGroup[group].includes("badge badge-danger")) {
|
||||
groupStatus = "<span class='text-warning'>~</span>";
|
||||
}
|
||||
output += ""
|
||||
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
|
||||
+ " <div class='container pt-2 border-left border-right border-top border-black border-bottom service-group' id='service-group-" + key + "' data-group='" + key + "' onclick='toggleGroup(this)'>"
|
||||
+ " <h5 class='text-secondary text-monospace pb-0'>"
|
||||
+ " " + groupStatus + " " + group
|
||||
+ " <span class='float-right service-group-arrow' id='service-group-" + key + "-arrow'>" + (isCurrentlyHidden ? "▼" : "▲") + "</span>"
|
||||
+ " </h5>"
|
||||
+ " </div>"
|
||||
+ " <div class='service-group-content' id='service-group-" + key + "-content' style='" + (isCurrentlyHidden ? "display: none;" : "") + "'>"
|
||||
+ " " + outputByGroup[group]
|
||||
+ " </div>"
|
||||
+ "</div>";
|
||||
}
|
||||
$("#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);
|
||||
|
@ -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()
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user