Work on #62: Add uptime percentage + badges

This commit is contained in:
TwinProduction
2020-12-29 20:22:17 -05:00
parent 3eb8299316
commit bc4380db19
25 changed files with 3457 additions and 80 deletions

108
controller/badge.go Normal file
View File

@ -0,0 +1,108 @@
package controller
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/watchdog"
"github.com/gorilla/mux"
)
// 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
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)
if uptime == nil {
writer.WriteHeader(http.StatusNotFound)
_, _ = writer.Write([]byte("Requested service not found"))
return
}
formattedDate := time.Now().Format(http.TimeFormat)
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
writer.Header().Set("Date", formattedDate)
writer.Header().Set("Expires", formattedDate)
writer.Header().Set("Content-Type", "image/svg+xml")
_, _ = writer.Write(generateSVG(duration, uptime))
}
func generateSVG(duration string, uptime *core.Uptime) []byte {
var labelWidth, valueWidth, valueWidthAdjustment int
var color string
var value float64
switch duration {
case "7d":
labelWidth = 65
value = uptime.LastSevenDays
case "24h":
labelWidth = 70
value = uptime.LastTwentyFourHours
case "1h":
labelWidth = 65
value = uptime.LastHour
default:
}
if value >= 0.8 {
color = "#40cc11"
} else {
color = "#c7130a"
}
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", value*100), "0"), ".") + "%"
if strings.Contains(sanitizedValue, ".") {
valueWidthAdjustment = -10
}
valueWidth = (len(sanitizedValue) * 11) + valueWidthAdjustment
width := labelWidth + valueWidth
labelX := labelWidth / 2
valueX := labelWidth + (valueWidth / 2)
svg := []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
<linearGradient id="b" x2="0" y2="100%%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<mask id="a">
<rect width="%d" height="20" rx="3" fill="#fff"/>
</mask>
<g mask="url(#a)">
<path fill="#555" d="M0 0h%dv20H0z"/>
<path fill="%s" d="M%d 0h%dv20H%dz"/>
<path fill="url(#b)" d="M0 0h%dv20H0z"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
uptime %s
</text>
<text x="%d" y="14">
uptime %s
</text>
<text x="%d" y="15" fill="#010101" fill-opacity=".3">
%s
</text>
<text x="%d" y="14">
%s
</text>
</g>
</svg>`, width, width, labelWidth, color, labelWidth, valueWidth, labelWidth, width, labelX, duration, labelX, duration, valueX, sanitizedValue, valueX, sanitizedValue))
return svg
}

100
controller/controller.go Normal file
View File

@ -0,0 +1,100 @@
package controller
import (
"bytes"
"compress/gzip"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/security"
"github.com/TwinProduction/gatus/watchdog"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const (
cacheTTL = 10 * time.Second
)
var (
cachedServiceStatuses []byte
cachedServiceStatusesGzipped []byte
cachedServiceStatusesTimestamp time.Time
)
// Handle creates the router and starts the server
func Handle() {
cfg := config.Get()
router := CreateRouter(cfg)
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 15 * time.Second,
}
log.Printf("[controller][Handle] Listening on %s%s\n", cfg.Web.SocketAddress(), cfg.Web.ContextRoot)
log.Fatal(server.ListenAndServe())
}
// CreateRouter creates the router for the http server
func CreateRouter(cfg *config.Config) *mux.Router {
router := mux.NewRouter()
statusesHandler := serviceStatusesHandler
if cfg.Security != nil && cfg.Security.IsValid() {
statusesHandler = security.Handler(serviceStatusesHandler, cfg.Security)
}
router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET") // favicon needs to be always served from the root
router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/statuses"), statusesHandler).Methods("GET")
router.HandleFunc(cfg.Web.PrependWithContextRoot("/api/v1/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")))))
if cfg.Metrics {
router.Handle(cfg.Web.PrependWithContextRoot("/metrics"), promhttp.Handler()).Methods("GET")
}
return router
}
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.GetJSONEncodedServiceStatuses()
if err != nil {
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()
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 = cachedServiceStatusesGzipped
} else {
data = cachedServiceStatuses
}
writer.Header().Add("Content-type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data)
}
func healthHandler(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Add("Content-type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte("{\"status\":\"UP\"}"))
}
// favIconHandler handles requests for /favicon.ico
func favIconHandler(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, "./static/favicon.ico")
}

51
controller/gzip.go Normal file
View File

@ -0,0 +1,51 @@
package controller
import (
"compress/gzip"
"io"
"io/ioutil"
"net/http"
"strings"
"sync"
)
var gzPool = sync.Pool{
New: func() interface{} {
return gzip.NewWriter(ioutil.Discard)
},
}
type gzipResponseWriter struct {
io.Writer
http.ResponseWriter
}
// WriteHeader sends an HTTP response header with the provided status code.
// It also deletes the Content-Length header, since the GZIP compression may modify the size of the payload
func (w *gzipResponseWriter) WriteHeader(status int) {
w.Header().Del("Content-Length")
w.ResponseWriter.WriteHeader(status)
}
// Write writes len(b) bytes from b to the underlying data stream.
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
// supports gzip encoding
func GzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(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)
return
}
writer.Header().Set("Content-Encoding", "gzip")
gz := gzPool.Get().(*gzip.Writer)
defer gzPool.Put(gz)
gz.Reset(writer)
defer gz.Close()
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: writer, Writer: gz}, r)
})
}