Merge pull request #80 from TwinProduction/vue

Migrate frontend to Vue + Add service detail page
This commit is contained in:
Chris C 2021-01-29 22:06:30 -05:00 committed by GitHub
commit 467874de10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 29217 additions and 478 deletions

View File

@ -3,3 +3,4 @@ Dockerfile
.github
.idea
.git
web/app

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -13,7 +13,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
FROM scratch
COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml
COPY --from=builder /app/static static/
COPY --from=builder /app/web/static ./app/web/static/
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV PORT=8080
EXPOSE ${PORT}

8
Makefile Normal file
View File

@ -0,0 +1,8 @@
docker-build:
docker build -t twinproduction/gatus:latest .
build-frontend:
npm --prefix web/app run build
run-frontend:
npm --prefix web/app run serve

View File

@ -1,4 +1,4 @@
![Gatus](static/logo-with-name.png)
![Gatus](assets/logo-with-name.png)
![build](https://github.com/TwinProduction/gatus/workflows/build/badge.svg?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwinProduction/gatus?)](https://goreportcard.com/report/github.com/TwinProduction/gatus)
@ -58,7 +58,7 @@ The main features of Gatus are:
- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty and Twilio are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks.
- **Metrics**
- **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small.
- **GitHub uptime badges** (ALPHA): ![Uptime 1h](https://status.twinnation.org/api/v1/badges/uptime/1h/group-core-service-twinnation%20-%20external.svg) ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/group-core-service-twinnation%20-%20external.svg) ![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/group-core-service-twinnation%20-%20external.svg)
- **GitHub uptime badges** (ALPHA): ![Uptime 1h](https://status.twinnation.org/api/v1/badges/uptime/1h/core_twinnation---external.svg) ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/core_twinnation---external.svg) ![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/core_twinnation---external.svg)
- **Service auto discovery in Kubernetes** (ALPHA)
@ -708,9 +708,9 @@ web:
```
### Uptime badges
![Uptime 1h](https://status.twinnation.org/api/v1/badges/uptime/1h/group-core-service-twinnation%20-%20external.svg)
![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/group-core-service-twinnation%20-%20external.svg)
![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/group-core-service-twinnation%20-%20external.svg)
![Uptime 1h](https://status.twinnation.org/api/v1/badges/uptime/1h/core_twinnation---external.svg)
![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/core_twinnation---external.svg)
![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/core_twinnation---external.svg)
> **NOTE**: This feature is currently in ALPHA
@ -737,7 +737,7 @@ If you want to display a service that is not part of a group, you must leave the
http://example.com/api/v1/badges/uptime/7d/group--service-frontend.svg
```
Example: ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/group-core-service-twinnation%20-%20external.svg)
Example: ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/core_twinnation---external.svg)
```
![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/group-core-service-twinnation%20-%20external.svg)
```

View File

@ -1,14 +1,14 @@
services:
- name: frontend
group: core
- name: Front End
group: Excuse Me
url: "https://twinnation.org/health"
interval: 1m
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 70"
- "[RESPONSE_TIME] < 23"
- name: backend
- name: back-end
group: core
url: "http://example.org/"
interval: 5m
@ -29,7 +29,7 @@ services:
conditions:
- "[STATUS] == 200"
- name: cat-fact
- name: cat fact
url: "https://cat-fact.herokuapp.com/facts/random"
interval: 5m
conditions:

View File

@ -14,26 +14,18 @@ import (
// badgeHandler handles the automatic generation of badge based on the group name and service name passed.
//
// Valid values for {duration}: 7d, 24h, 1h
// Pattern for {identifier}: group-<GROUP_NAME>-service-<SERVICE_NAME>.svg
// Pattern for {identifier}: <KEY>.svg
func badgeHandler(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
// group-<GROUP_NAME>-service-<SERVICE_NAME>.svg
identifier := variables["identifier"]
if duration != "7d" && duration != "24h" && duration != "1h" {
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
return
}
parts := strings.Split(identifier, "-service-")
if len(parts) != 2 || !strings.HasPrefix(identifier, "group-") || !strings.HasSuffix(identifier, ".svg") {
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte("Invalid path: Pattern should look like /group-<GROUP_NAME>-service-<SERVICE_NAME>.svg"))
return
}
groupName := strings.TrimPrefix(parts[0], "group-")
serviceName := strings.TrimSuffix(parts[1], ".svg")
uptime := watchdog.GetUptimeByServiceGroupAndName(groupName, serviceName)
identifier := variables["identifier"]
key := strings.TrimSuffix(identifier, ".svg")
uptime := watchdog.GetUptimeByKey(key)
if uptime == nil {
writer.WriteHeader(http.StatusNotFound)
_, _ = writer.Write([]byte("Requested service not found"))

View File

@ -3,9 +3,11 @@ package controller
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
@ -34,7 +36,10 @@ func init() {
// Handle creates the router and starts the server
func Handle() {
cfg := config.Get()
router := CreateRouter(cfg)
var router http.Handler = CreateRouter(cfg)
if os.Getenv("ENVIRONMENT") == "dev" {
router = developmentCorsHandler(router)
}
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
Handler: router,
@ -49,21 +54,29 @@ 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("/services/{service}", spaHandler).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")))))
router.PathPrefix(cfg.Web.ContextRoot).Handler(GzipHandler(http.StripPrefix(cfg.Web.ContextRoot, http.FileServer(http.Dir("./web/static")))))
if cfg.Metrics {
router.Handle(cfg.Web.PrependWithContextRoot("/metrics"), promhttp.Handler()).Methods("GET")
}
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
@ -81,7 +94,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
@ -102,6 +115,35 @@ 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 := map[string]interface{}{
"serviceStatus": serviceStatus,
// This is my lazy way of exposing events even though they're not visible from the json annotation
// present in ServiceStatus. We do this because creating a separate object for each endpoints
// would be wasteful (one with and one without Events)
"events": serviceStatus.Events,
}
output, err := json.Marshal(data)
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(output)
}
func healthHandler(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
@ -110,5 +152,10 @@ func healthHandler(writer http.ResponseWriter, _ *http.Request) {
// favIconHandler handles requests for /favicon.ico
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, "./static/favicon.ico")
http.ServeFile(writer, request, "./web/static/favicon.ico")
}
// spaHandler handles requests for /favicon.ico
func spaHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, "./web/static/index.html")
}

10
controller/cors.go Normal file
View File

@ -0,0 +1,10 @@
package controller
import "net/http"
func developmentCorsHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
next.ServeHTTP(w, r)
})
}

View File

@ -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)
})
}
}

26
core/event.go Normal file
View File

@ -0,0 +1,26 @@
package core
import "time"
// Event is something that happens at a specific time
type Event struct {
// Type is the kind of event
Type EventType `json:"type"`
// Timestamp is the moment at which the event happened
Timestamp time.Time `json:"timestamp"`
}
// EventType is, uh, the types of events?
type EventType string
var (
// EventStart is a type of event that represents when a service starts being monitored
EventStart EventType = "START"
// EventHealthy is a type of event that represents a service passing all of its conditions
EventHealthy EventType = "HEALTHY"
// EventUnhealthy is a type of event that represents a service failing one or more of its conditions
EventUnhealthy EventType = "UNHEALTHY"
)

View File

@ -31,7 +31,7 @@ type Result struct {
Errors []string `json:"errors"`
// ConditionResults results of the service's conditions
ConditionResults []*ConditionResult `json:"condition-results"`
ConditionResults []*ConditionResult `json:"conditionResults"`
// Success whether the result signifies a success or not
Success bool `json:"success"`

View File

@ -1,5 +1,11 @@
package core
import (
"time"
"github.com/TwinProduction/gatus/util"
)
// ServiceStatus contains the evaluation Results of a Service
type ServiceStatus struct {
// Name of the service
@ -8,9 +14,19 @@ 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"`
// Events is a list of events
//
// We don't expose this through JSON, because the main dashboard doesn't need to have these events.
// However, the detailed service page does leverage this by including it to a map that will be
// marshalled alongside the ServiceStatus.
Events []*Event `json:"-"`
// Uptime information on the service's uptime
Uptime *Uptime `json:"uptime"`
}
@ -20,7 +36,12 @@ func NewServiceStatus(service *Service) *ServiceStatus {
return &ServiceStatus{
Name: service.Name,
Group: service.Group,
Key: util.ConvertGroupAndServiceToKey(service.Group, service.Name),
Results: make([]*Result, 0),
Events: []*Event{{
Type: EventStart,
Timestamp: time.Now(),
}},
Uptime: NewUptime(),
}
}
@ -28,6 +49,22 @@ func NewServiceStatus(service *Service) *ServiceStatus {
// 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) {
if len(ss.Results) > 0 {
// Check if there's any change since the last result
// OR there's only 1 event, which only happens when there's a start event
if ss.Results[len(ss.Results)-1].Success != result.Success || len(ss.Events) == 1 {
event := &Event{Timestamp: result.Timestamp}
if result.Success {
event.Type = EventHealthy
} else {
event.Type = EventUnhealthy
}
ss.Events = append(ss.Events, event)
if len(ss.Events) > 20 {
ss.Events = ss.Events[1:]
}
}
}
ss.Results = append(ss.Results, result)
if len(ss.Results) > 20 {
ss.Results = ss.Results[1:]

View File

@ -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) {

File diff suppressed because one or more lines are too long

View File

@ -1,422 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Health Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="./bootstrap.min.css" />
<style>
html, body {
background-color: #f7f9fb;
}
html {
height: 100%;
}
#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;
}
.status {
cursor: pointer;
transition: all 500ms ease-in-out;
overflow-x: hidden;
padding: .25em 0;
color: white;
}
.title {
font-size: 2.5rem;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
color: black;
}
.status-over-time {
overflow: auto;
}
.status-over-time>span:not(:last-child) {
margin-left: 2px;
}
.status-time-ago {
color: #6a737d;
opacity: 0.5;
margin-top: 5px;
}
.status-min-max-ms {
overflow-x: hidden;
}
#tooltip {
position: fixed;
top: 0;
left: 0;
background-color: white;
border: 1px solid lightgray;
border-radius: 4px;
padding: 6px;
font-size: 13px;
}
#tooltip code {
color: #212529;
line-height: 1;
}
#tooltip .tooltip-title {
font-weight: bold;
margin-bottom: 0;
display: block;
}
#tooltip .tooltip-title {
margin-top: 8px;
}
#tooltip>.tooltip-title:first-child {
margin-top: 0;
}
#social {
position: fixed;
right: 5px;
bottom: 5px;
padding: 5px;
margin: 0;
z-index: 100;
}
#social img {
opacity: 0.3;
}
#social img:hover {
opacity: 1;
}
#settings {
position: fixed;
left: 5px;
bottom: 5px;
padding: 5px;
}
#settings select:focus {
box-shadow: none;
}
.service-group {
cursor: pointer;
user-select: none;
}
.service-group h5:hover {
color: #1b1e21 !important;
}
</style>
</head>
<body>
<div class="container my-3 rounded p-3 border shadow">
<div class="mb-2">
<div class="row">
<div class="col-8 text-left my-auto">
<div class="title display-4">Health Status</div>
</div>
<div class="col-4 text-right">
<img src="logo.png" alt="Gatus" style="position: relative; min-width: 50px; max-width: 200px; width: 20%;"/>
</div>
</div>
</div>
<div id="results"></div>
</div>
<div id="tooltip" style="display: none">
<div class="tooltip-title">Timestamp:</div>
<code id="tooltip-timestamp">...</code>
<div class="tooltip-title">Response time:</div>
<code id="tooltip-response-time">...</code>
<div class="tooltip-title">Conditions:</div>
<code id="tooltip-conditions">...</code>
<div id="tooltip-errors-container">
<div class="tooltip-title">Errors:</div>
<code id="tooltip-errors">...</code>
</div>
</div>
<script src="./jquery.min.js"></script>
<div id="social">
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
<img src="./github.png" alt="GitHub" width="32" height="auto" />
</a>
</div>
<div id="settings">
<div class="input-group input-group-sm">
<div class="input-group-prepend">
<div class="input-group-text">&#x21bb;</div>
</div>
<select class="form-control form-control-sm" id="refresh-rate">
<option value="10">10s</option>
<option value="30" selected>30s</option>
<option value="60">1m</option>
<option value="120">2m</option>
<option value="300">5m</option>
<option value="600">10m</option>
</select>
</div>
</div>
<script>
let maximumNumberOfResults = 20;
let serviceStatuses = {};
let timerHandler = 0;
let refreshIntervalHandler = 0;
let userClickedStatus = false;
// TODO: make this variable configurable and persist the choice in localStorage
let showStatusOnHover = true;
function showTooltip(serviceName, index, element) {
//userClickedStatus = false;
clearTimeout(timerHandler);
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
let conditions = "";
for (let i in serviceResult['condition-results']) {
let conditionResult = serviceResult['condition-results'][i];
conditions += (conditionResult.success ? "&#10003;" : "X") + " ~ " + htmlEntities(conditionResult.condition) + "<br />";
}
$("#tooltip-conditions").html(conditions);
// Populate the error section only if there are errors
if (serviceResult.errors && serviceResult.errors.length > 0) {
let errors = "";
for (let i in serviceResult.errors) {
errors += "- " + htmlEntities(serviceResult.errors[i]) + "<br />";
}
$("#tooltip-errors").html(errors);
$("#tooltip-errors-container").show();
} else {
$("#tooltip-errors-container").hide();
}
// Position tooltip
$("#tooltip").css({top: "0px", left: "0px"}).show();
let targetTopPosition = element.getBoundingClientRect().y + 30;
let targetLeftPosition = element.getBoundingClientRect().x;
// Make adjustments if necessary
let tooltipBoundingClientRect = document.querySelector('#tooltip').getBoundingClientRect();
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
targetLeftPosition = element.getBoundingClientRect().x - tooltipBoundingClientRect.width + element.getBoundingClientRect().width;
if (targetLeftPosition < 0) {
targetLeftPosition += -targetLeftPosition;
}
}
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
targetTopPosition = element.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
if (targetTopPosition < 0) {
targetTopPosition = element.getBoundingClientRect().y + 30;
}
}
$("#tooltip").css({top: targetTopPosition + "px", left: targetLeftPosition + "px"});
}
function fadeTooltip() {
if (!userClickedStatus) {
timerHandler = setTimeout(function () {
$("#tooltip").hide();
}, 50);
}
}
function toggleTooltip(serviceName, index, element) {
console.log("userClickedStatus="+userClickedStatus);
if (!userClickedStatus) {
showTooltip(serviceName, index, element);
userClickedStatus = true;
} else {
$("#tooltip").hide();
userClickedStatus = false;
}
}
function createStatusBadge(serviceStatusIndex, index, success) {
if (success) {
if (showStatusOnHover) {
return "<span class='status badge badge-success' style='width: 5%' onmouseenter='showTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>&#10003;</span>";
} else {
return "<span class='status badge badge-success' style='width: 5%' onclick='toggleTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)'>&#10003;</span>";
}
}
if (showStatusOnHover) {
return "<span class='status badge badge-danger' style='width: 5%' onmouseenter='showTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)' onmouseleave='fadeTooltip()' onclick='userClickedStatus = !userClickedStatus;'>X</span>";
} else {
return "<span class='status badge badge-danger' style='width: 5%' onclick='toggleTooltip(\"" + serviceStatusIndex + "\", " + index + ", this)'>X</span>";
}
}
function createBlankStatusBadge() {
return "<span class='status badge badge-light border' style='width: 5%;'> </span>";
}
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;
buildTable();
}
});
}
function buildTable() {
let outputByGroup = {};
for (let serviceStatusIndex in serviceStatuses) {
let serviceStatusOverTime = "";
let serviceStatus = serviceStatuses[serviceStatusIndex];
let hostname = serviceStatus.results[serviceStatus.results.length-1].hostname;
let minResponseTime = null;
let maxResponseTime = null;
let newestTimestamp = null;
let oldestTimestamp = null;
for (let resultIndex in serviceStatus.results) {
let serviceResult = serviceStatus.results[resultIndex];
serviceStatusOverTime = createStatusBadge(serviceStatusIndex, resultIndex, serviceResult.success) + serviceStatusOverTime;
const responseTime = parseInt(serviceResult.duration/1000000);
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
const timestamp = new Date(serviceResult.timestamp);
if (newestTimestamp == null || newestTimestamp < timestamp) {
newestTimestamp = timestamp;
}
if (oldestTimestamp == null || oldestTimestamp > timestamp) {
oldestTimestamp = timestamp;
}
}
for (let i = serviceStatus.results.length; i < maximumNumberOfResults; i++) {
serviceStatusOverTime += createBlankStatusBadge();
}
let output = ""
+ "<div class='container py-3 border-left border-right border-top rounded-0'>"
+ " <div class='row mb-2'>"
+ " <div class='col-md-10'>"
+ " <span class='font-weight-bold'>" + serviceStatus.name + "</span> <span class='text-secondary font-weight-lighter'>- " + hostname + "</span>"
+ " </div>"
+ " <div class='col-md-2 text-right'>"
+ " <span class='font-weight-lighter status-min-max-ms'>" + (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) + "ms</span>"
+ " </div>"
+ " </div>"
+ " <div class='row'>"
+ " <div class='col-12 d-flex flex-row-reverse status-over-time'>"
+ " " + serviceStatusOverTime
+ " </div>"
+ " </div>"
+ " <div class='row status-time-ago'>"
+ " <div class='col-6'>"
+ " " + generatePrettyTimeAgo(oldestTimestamp)
+ " </div>"
+ " <div class='col-6 text-right'>"
+ " " + generatePrettyTimeAgo(newestTimestamp)
+ " </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) {
// 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';
let groupStatus = "<span class='text-success'>&#10003;</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-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 ? "&#9660;" : "&#9650;") + "</span>"
+ " </h5>"
+ " </div>"
+ " <div class='service-group-content' id='service-group-" + key + "-content' style='" + (isCurrentlyHidden ? "display: none;" : "") + "'>"
+ " " + outputByGroup[group]
+ " </div>"
+ "</div>";
}
// Add all services that don't have a group at the end
if (outputByGroup['undefined']) {
output += ""
+ "<div class='mt-" + (output.length ? '4' : '3') + "'>"
+ " " + outputByGroup['undefined']
+ "</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("&#9660;");
} else {
$("#service-group-" + element.dataset.group + "-arrow").html("&#9650;");
}
});
}
function prettifyTimestamp(timestamp) {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
let MM = ((date.getMonth()+1)<10?"0":"")+""+(date.getMonth()+1);
let DD = ((date.getDate())<10?"0":"")+""+(date.getDate());
let hh = ((date.getHours())<10?"0":"")+""+(date.getHours());
let mm = ((date.getMinutes())<10?"0":"")+""+(date.getMinutes());
let ss = ((date.getSeconds())<10?"0":"")+""+(date.getSeconds());
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
}
function generatePrettyTimeAgo(t) {
let differenceInMs = new Date().getTime() - new Date(t).getTime();
if (differenceInMs > 3600000) {
let hours = (differenceInMs/3600000).toFixed(0);
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
}
if (differenceInMs > 60000) {
let minutes = (differenceInMs/60000).toFixed(0);
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
}
return (differenceInMs/1000).toFixed(0) + " seconds ago";
}
function htmlEntities(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function setRefreshInterval(seconds) {
refreshStatuses();
refreshIntervalHandler = setInterval(function() {
refreshStatuses();
}, seconds * 1000);
}
$("#refresh-rate").change(function() {
clearInterval(refreshIntervalHandler);
setRefreshInterval($(this).val());
});
setRefreshInterval(30);
$("#refresh-rate").val(30);
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

View File

@ -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 {

View File

@ -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,"condition-results":[{"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","key":"group_name","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
View 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
View 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)
}
})
}
}

View File

@ -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 {

23
web/app/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
web/app/README.md Normal file
View File

@ -0,0 +1,24 @@
# app
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
web/app/babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

27934
web/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
web/app/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "app",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --mode development",
"build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@tailwindcss/postcss7-compat": "^2.0.2",
"autoprefixer": "^9.8.6",
"core-js": "^3.6.5",
"postcss": "^7.0.35",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2",
"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",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0-0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

View File

@ -0,0 +1,5 @@
module.exports = {
plugins: [
require('tailwindcss')
],
};

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

17
web/app/public/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Health Dashboard | Gatus</title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

55
web/app/src/App.vue Normal file
View File

@ -0,0 +1,55 @@
<template>
<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-3/4 text-left my-auto">
<div class="title text-5xl font-light">Health Status</div>
</div>
<div class="w-1/4 flex justify-end">
<img src="./assets/logo.png" alt="Gatus" class="object-scale-down" style="max-width: 100px; min-width: 50px; min-height:50px;"/>
</div>
</div>
</div>
<router-view @showTooltip="showTooltip"/>
</div>
<Tooltip :result="tooltip.result" :event="tooltip.event"/>
<Social/>
</template>
<script>
import Social from './components/Social.vue'
import Tooltip from './components/Tooltip.vue';
export default {
name: 'App',
components: {
Social,
Tooltip
},
methods: {
showTooltip(result, event) {
this.tooltip = {result: result, event: event};
}
},
data() {
return {
tooltip: {}
}
},
}
</script>
<style>
html {
height: 100%;
}
html, body {
background-color: #f7f9fb;
}
#global, #results {
max-width: 1200px;
}
</style>

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -0,0 +1,144 @@
<template>
<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'>
<router-link :to="generatePath()" class="inline-block font-bold transform hover:scale-110 transition duration-100 ease-in-out hover:text-blue-800" title="View detailed service health">{{ 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'>
{{ (minResponseTime === maxResponseTime ? minResponseTime : (minResponseTime + "-" + maxResponseTime)) }}ms
</span>
</div>
</div>
<div>
<div class='status-over-time flex flex-row'>
<slot v-for="filler in maximumNumberOfResults - data.results.length" :key="filler">
<span class="status rounded border border-dashed"> </span>
</slot>
<slot v-for="result in data.results" :key="result">
<span v-if="result.success" class="status rounded bg-success" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)">&#10003;</span>
<span v-else class="status rounded bg-red-600" @mouseenter="showTooltip(result, $event)" @mouseleave="showTooltip(null, $event)">X</span>
</slot>
</div>
</div>
<div class='flex flex-wrap status-time-ago'>
<!-- Show "Last update at" instead? -->
<div class='w-1/2'>
{{ generatePrettyTimeAgo(data.results[0].timestamp) }}
</div>
<div class='w-1/2 text-right'>
{{ generatePrettyTimeAgo(data.results[data.results.length - 1].timestamp) }}
</div>
</div>
</div>
</template>
<script>
import {helper} from "@/mixins/helper";
export default {
name: 'Service',
props: {
maximumNumberOfResults: Number,
data: Object,
},
emits: ['showTooltip'],
mixins: [helper],
methods: {
updateMinAndMaxResponseTimes() {
let minResponseTime = null;
let maxResponseTime = null;
for (let i in this.data.results) {
const responseTime = parseInt(this.data.results[i].duration/1000000);
if (minResponseTime == null || minResponseTime > responseTime) {
minResponseTime = responseTime;
}
if (maxResponseTime == null || maxResponseTime < responseTime) {
maxResponseTime = responseTime;
}
}
if (this.minResponseTime !== minResponseTime) {
this.minResponseTime = minResponseTime;
}
if (this.maxResponseTime !== maxResponseTime) {
this.maxResponseTime = maxResponseTime;
}
},
generatePath() {
if (!this.data) {
return "/";
}
return "/services/" + this.data.key;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
}
},
watch: {
data: function () {
this.updateMinAndMaxResponseTimes();
}
},
created() {
this.updateMinAndMaxResponseTimes()
},
data() {
return {
minResponseTime: 0,
maxResponseTime: 0
}
}
}
</script>
<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;
overflow-x: hidden;
color: white;
width: 5%;
font-size: 75%;
font-weight: 700;
text-align: center;
}
.status:hover {
opacity: 0.7;
transition: opacity 100ms ease-in-out;
color: black;
}
.status-over-time {
overflow: auto;
}
.status-over-time > span:not(:first-child) {
margin-left: 2px;
}
.status-time-ago {
color: #6a737d;
opacity: 0.5;
margin-top: 5px;
}
.status-min-max-ms {
overflow-x: hidden;
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<div :class="services.length === 0 ? 'mt-3' : 'mt-4'">
<slot v-if="name !== 'undefined'">
<div class="service-group container pt-2 border" @click="toggleGroup">
<h5 class='text-monospace text-gray-400 text-xl font-medium pb-2 px-3'>
<span v-if="healthy" class='text-green-600'>&#10003;</span>
<span v-else class='text-yellow-400'>~</span>
{{ name }}
<span class='float-right service-group-arrow'>
{{ collapsed ? '&#9660;' : '&#9650;' }}
</span>
</h5>
</div>
</slot>
<div v-if="!collapsed" :class="name === 'undefined' ? '' : 'service-group-content'">
<slot v-for="service in services" :key="service">
<Service :data="service" @showTooltip="showTooltip" :maximumNumberOfResults="20" />
</slot>
</div>
</div>
</template>
<script>
import Service from './Service.vue';
export default {
name: 'ServiceGroup',
components: {
Service
},
props: {
name: String,
services: Array
},
emits: ['showTooltip'],
methods: {
healthCheck() {
if (this.services) {
for (let i in this.services) {
for (let j in this.services[i].results) {
if (!this.services[i].results[j].success) {
// Set the service group to unhealthy (only if it's currently healthy)
if (this.healthy) {
this.healthy = false;
}
return;
}
}
}
}
// Set the service group to healthy (only if it's currently unhealthy)
if (!this.healthy) {
this.healthy = true;
}
},
toggleGroup() {
this.collapsed = !this.collapsed;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
}
},
watch: {
services: function () {
this.healthCheck();
}
},
created() {
this.healthCheck();
},
data() {
return {
healthy: true,
collapsed: false
}
}
}
</script>
<style>
.service-group {
cursor: pointer;
user-select: none;
}
.service-group h5:hover {
color: #1b1e21 !important;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<div id="results">
<slot v-for="serviceGroup in serviceGroups" :key="serviceGroup">
<ServiceGroup :services="serviceGroup.services" :name="serviceGroup.name" @showTooltip="showTooltip"/>
</slot>
</div>
</template>
<script>
import ServiceGroup from './ServiceGroup.vue';
export default {
name: 'Services',
components: {
ServiceGroup
},
props: {
showStatusOnHover: Boolean,
serviceStatuses: Object
},
emits: ['showTooltip'],
methods: {
process() {
let outputByGroup = {};
for (let serviceStatusIndex in this.serviceStatuses) {
let serviceStatus = this.serviceStatuses[serviceStatusIndex];
// create an empty entry if this group is new
if (!outputByGroup[serviceStatus.group] || outputByGroup[serviceStatus.group].length === 0) {
outputByGroup[serviceStatus.group] = [];
}
outputByGroup[serviceStatus.group].push(serviceStatus);
}
let serviceGroups = [];
for (let name in outputByGroup) {
if (name !== 'undefined') {
serviceGroups.push({name: name, services: outputByGroup[name]})
}
}
// Add all services that don't have a group at the end
if (outputByGroup['undefined']) {
serviceGroups.push({name: 'undefined', services: outputByGroup['undefined']})
}
this.serviceGroups = serviceGroups;
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
}
},
watch: {
serviceStatuses: function () {
this.process();
}
},
data() {
return {
userClickedStatus: false,
serviceGroups: []
}
}
}
</script>
<style>
.service-group-content > div:nth-child(1) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<div id="settings">
<div class="flex bg-gray-200 rounded border border-gray-300 shadow">
<div class="text-sm text-gray-600 rounded-xl py-1 px-2">
&#x21bb;
</div>
<select class="text-center text-gray-500 text-sm" id="refresh-rate" ref="refreshInterval" @change="handleChangeRefreshInterval">
<option value="10">10s</option>
<option value="30" selected>30s</option>
<option value="60">1m</option>
<option value="120">2m</option>
<option value="300">5m</option>
<option value="600">10m</option>
</select>
</div>
</div>
</template>
<script>
export default {
name: 'Settings',
props: {},
methods: {
setRefreshInterval(seconds) {
let that = this;
this.refreshIntervalHandler = setInterval(function () {
that.refreshData();
}, seconds * 1000);
},
refreshData() {
this.$emit('refreshData');
},
handleChangeRefreshInterval() {
this.refreshData();
clearInterval(this.refreshIntervalHandler);
this.setRefreshInterval(this.$refs.refreshInterval.value);
}
},
created() {
this.setRefreshInterval(this.refreshInterval);
},
unmounted() {
clearInterval(this.refreshIntervalHandler);
},
data() {
return {
refreshInterval: 30,
refreshIntervalHandler: 0,
}
},
}
// props.refreshInterval = 30
//$("#refresh-rate").val(30);
</script>
<style scoped>
#settings {
position: fixed;
left: 5px;
bottom: 5px;
padding: 5px;
}
#settings select:focus {
box-shadow: none;
}
</style>

View File

@ -0,0 +1,34 @@
<template>
<div id="social">
<a href="https://github.com/TwinProduction/gatus" target="_blank" title="Gatus on GitHub">
<img src="../assets/github.png" alt="GitHub" width="32" height="auto"/>
</a>
</div>
</template>
<script>
export default {
name: 'Social'
}
</script>
<style scoped>
#social {
position: fixed;
right: 5px;
bottom: 5px;
padding: 5px;
margin: 0;
z-index: 100;
}
#social img {
opacity: 0.3;
}
#social img:hover {
opacity: 1;
}
</style>

View File

@ -0,0 +1,135 @@
<template>
<div id="tooltip" ref="tooltip" :class="hidden ? 'invisible' : ''" :style="'top:' + top + 'px; left:' + left + 'px'">
<slot v-if="result">
<div class="tooltip-title">Timestamp:</div>
<code id="tooltip-timestamp">{{ prettifyTimestamp(result.timestamp) }}</code>
<div class="tooltip-title">Response time:</div>
<code id="tooltip-response-time">{{ (result.duration / 1000000).toFixed(0) }}ms</code>
<div class="tooltip-title">Conditions:</div>
<code id="tooltip-conditions">
<slot v-for="conditionResult in result.conditionResults" :key="conditionResult">
{{ conditionResult.success ? "&#10003;" : "X" }} ~ {{ conditionResult.condition }}<br/>
</slot>
</code>
<div id="tooltip-errors-container" v-if="result.errors && result.errors.length">
<div class="tooltip-title">Errors:</div>
<code id="tooltip-errors">
<slot v-for="error in result.errors" :key="error">
- {{ error }}<br/>
</slot>
</code>
</div>
</slot>
</div>
</template>
<script>
export default {
name: 'Services',
props: {
event: Event,
result: Object
},
methods: {
prettifyTimestamp(timestamp) {
let date = new Date(timestamp);
let YYYY = date.getFullYear();
let MM = ((date.getMonth() + 1) < 10 ? "0" : "") + "" + (date.getMonth() + 1);
let DD = ((date.getDate()) < 10 ? "0" : "") + "" + (date.getDate());
let hh = ((date.getHours()) < 10 ? "0" : "") + "" + (date.getHours());
let mm = ((date.getMinutes()) < 10 ? "0" : "") + "" + (date.getMinutes());
let ss = ((date.getSeconds()) < 10 ? "0" : "") + "" + (date.getSeconds());
return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
},
htmlEntities(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
},
reposition() {
if (this.event && this.event.type) {
if (this.event.type === 'mouseenter') {
let targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
let targetLeftPosition = this.event.target.getBoundingClientRect().x;
let tooltipBoundingClientRect = this.$refs.tooltip.getBoundingClientRect();
if (targetLeftPosition + window.scrollX + tooltipBoundingClientRect.width + 50 > document.body.getBoundingClientRect().width) {
targetLeftPosition = this.event.target.getBoundingClientRect().x - tooltipBoundingClientRect.width + this.event.target.getBoundingClientRect().width;
if (targetLeftPosition < 0) {
targetLeftPosition += -targetLeftPosition;
}
}
if (targetTopPosition + window.scrollY + tooltipBoundingClientRect.height + 50 > document.body.getBoundingClientRect().height && targetTopPosition >= 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y - (tooltipBoundingClientRect.height + 10);
if (targetTopPosition < 0) {
targetTopPosition = this.event.target.getBoundingClientRect().y + 30;
}
}
this.top = targetTopPosition;
this.left = targetLeftPosition;
} else if (this.event.type === 'mouseleave') {
this.hidden = true;
}
}
}
},
watch: {
event: function (value) {
if (value && value.type) {
if (value.type === 'mouseenter') {
this.hidden = false;
} else if (value.type === 'mouseleave') {
this.hidden = true;
}
}
}
},
updated() {
this.reposition();
},
created() {
this.reposition();
},
data() {
return {
hidden: false,
top: 0,
left: 0
}
}
}
</script>
<style>
#tooltip {
position: fixed;
background-color: white;
border: 1px solid lightgray;
border-radius: 4px;
padding: 6px;
font-size: 13px;
}
#tooltip code {
color: #212529;
line-height: 1;
}
#tooltip .tooltip-title {
font-weight: bold;
margin-bottom: 0;
display: block;
}
#tooltip .tooltip-title {
margin-top: 8px;
}
#tooltip > .tooltip-title:first-child {
margin-top: 0;
}
</style>

11
web/app/src/index.css Normal file
View File

@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.bg-success {
background-color: #28a745;
}
.text-monospace {
font-family: Consolas, monospace;
}

8
web/app/src/main.js Normal file
View File

@ -0,0 +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).use(router).mount('#app')

View File

@ -0,0 +1,16 @@
export const helper = {
methods: {
generatePrettyTimeAgo(t) {
let differenceInMs = new Date().getTime() - new Date(t).getTime();
if (differenceInMs > 3600000) {
let hours = (differenceInMs / 3600000).toFixed(0);
return hours + " hour" + (hours !== "1" ? "s" : "") + " ago";
}
if (differenceInMs > 60000) {
let minutes = (differenceInMs / 60000).toFixed(0);
return minutes + " minute" + (minutes !== "1" ? "s" : "") + " ago";
}
return (differenceInMs / 1000).toFixed(0) + " seconds ago";
},
}
}

View 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

View File

@ -0,0 +1,164 @@
<template>
<router-link to="/" class="absolute top-2 left-2 inline-block px-2 py-0 text-lg text-black transition bg-gray-100 rounded shadow ripple hover:shadow-lg hover:bg-gray-200 focus:outline-none">
&larr;
</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>
<div v-if="serviceStatus.uptime" class="mt-12">
<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>
<hr class="mt-1"/>
<h3 class="text-xl text-monospace text-gray-400 mt-1 text-right">BADGES</h3>
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-12">
<div class="flex-1">
<img :src="generateBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto" />
</div>
<div class="flex-1">
<img :src="generateBadgeImageURL('24h')" alt="24h uptime badge" class="mx-auto" />
</div>
<div class="flex-1">
<img :src="generateBadgeImageURL('1h')" alt="1h uptime badge" class="mx-auto" />
</div>
</div>
</div>
<div>
<h1 class="text-3xl text-monospace text-gray-400 mt-4">EVENTS</h1>
<hr class="mb-4" />
<div>
<slot v-for="event in events" :key="event">
<div class="p-3 my-4">
<h2 class="text-lg">
<span v-if="event.type === 'HEALTHY'" class="border border-green-600 rounded-full px-1 text-green-700 opacity-75 bg-green-100 mr-2"><span class="relative bottom-0.5">🡡</span></span>
<span v-else-if="event.type === 'UNHEALTHY'" class="border border-red-500 rounded-full px-1 text-red-700 opacity-75 bg-red-100 mr-2">🡣</span>
<span v-else-if="event.type === 'START'" class="mr-2"></span>
{{ event.fancyText }}
</h2>
<div class="flex mt-1 text-sm text-gray-400">
<div class="flex-1 text-left pl-10">
{{ new Date(event.timestamp).toISOString() }}
</div>
<div class="flex-1 text-right">
{{ event.fancyTimeAgo }}
</div>
</div>
</div>
</slot>
</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";
import {helper} from "@/mixins/helper.js";
export default {
name: 'Details',
components: {
Service,
Settings,
},
emits: ['showTooltip'],
mixins: [helper],
methods: {
fetchData() {
console.log("[Details][fetchData] Fetching data");
fetch(`${this.serverUrl}/api/v1/statuses/${this.$route.params.key}`)
.then(response => response.json())
.then(data => {
if (JSON.stringify(this.serviceStatus) !== JSON.stringify(data)) {
this.serviceStatus = data.serviceStatus;
let events = [];
for (let i = data.events.length-1; i >= 0; i--) {
let event = data.events[i];
if (i === data.events.length-1) {
if (event.type === "UNHEALTHY") {
event.fancyText = "Service is unhealthy";
} else if (event.type === "HEALTHY") {
event.fancyText = "Service is healthy";
} else if (event.type === "START") {
event.fancyText = "Monitoring started";
}
} else {
let nextEvent = data.events[i+1];
if (event.type === "HEALTHY") {
event.fancyText = "Service became healthy again";
} else if (event.type === "UNHEALTHY") {
if (nextEvent) {
event.fancyText = "Service was unhealthy for " + this.prettifyTimeDifference(nextEvent.timestamp, event.timestamp);
} else {
event.fancyText = "Service became unhealthy";
}
} else if (event.type === "START") {
event.fancyText = "Monitoring started";
}
}
event.fancyTimeAgo = this.generatePrettyTimeAgo(event.timestamp);
events.push(event);
}
this.events = events;
}
});
},
generateBadgeImageURL(duration) {
return `${this.serverUrl}/api/v1/badges/uptime/${duration}/${this.serviceStatus.key}`;
},
prettifyUptime(uptime) {
if (!uptime) {
return "0%";
}
return (uptime * 100).toFixed(2) + "%"
},
prettifyTimeDifference(start, end) {
let minutes = Math.ceil((new Date(start) - new Date(end))/1000/60);
return minutes + (minutes === 1 ? " minute" : " minutes");
},
showTooltip(result, event) {
this.$emit('showTooltip', result, event);
}
},
data() {
return {
serviceStatus: {},
events: [],
// Since this page isn't at the root, we need to modify the server URL a bit
serverUrl: SERVER_URL === '.' ? '..' : SERVER_URL,
}
},
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>

View 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>

View File

@ -0,0 +1,11 @@
module.exports = {
purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
}

5
web/app/vue.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
filenameHashing: false,
productionSourceMap: false,
outputDir: '../static'
}

3
web/static/css/app.css Normal file

File diff suppressed because one or more lines are too long

BIN
web/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
web/static/img/github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
web/static/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

1
web/static/index.html Normal file
View File

@ -0,0 +1 @@
<!DOCTYPE html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>Health Dashboard | Gatus</title><link href="/css/app.css" rel="preload" as="style"><link href="/js/app.js" rel="preload" as="script"><link href="/js/chunk-vendors.js" rel="preload" as="script"><link href="/css/app.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/js/chunk-vendors.js"></script><script src="/js/app.js"></script></body></html>

1
web/static/js/app.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long