106
api/api.go
Normal file
106
api/api.go
Normal file
@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
"github.com/TwiN/health"
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
fiberfs "github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/gofiber/fiber/v2/middleware/redirect"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
router *fiber.App
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *API {
|
||||
api := &API{}
|
||||
api.router = api.createRouter(cfg)
|
||||
return api
|
||||
}
|
||||
|
||||
func (a *API) Router() *fiber.App {
|
||||
return a.router
|
||||
}
|
||||
|
||||
func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||
log.Printf("[api.ErrorHandler] %s", err.Error())
|
||||
return fiber.DefaultErrorHandler(c, err)
|
||||
},
|
||||
})
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "http://localhost:8081",
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
}
|
||||
apiRouter := app.Group("/api")
|
||||
protectedAPIRouter := apiRouter.Group("/")
|
||||
unprotectedAPIRouter := apiRouter.Group("/")
|
||||
if cfg.Metrics {
|
||||
metricsHandler := promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
|
||||
DisableCompression: true,
|
||||
}))
|
||||
app.Get("/metrics", adaptor.HTTPHandler(metricsHandler))
|
||||
}
|
||||
// Security (ORDER IS IMPORTANT: middlewares must be applied before the routes are registered)
|
||||
if cfg.Security != nil {
|
||||
if err := cfg.Security.RegisterHandlers(app); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
// Middlewares
|
||||
app.Use(recover.New())
|
||||
app.Use(compress.New())
|
||||
// Routes
|
||||
handler := ConfigHandler{securityConfig: cfg.Security}
|
||||
unprotectedAPIRouter.Get("/v1/config", handler.GetConfig)
|
||||
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
|
||||
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
|
||||
// SPA
|
||||
app.Get("/", SinglePageApplication(cfg.UI))
|
||||
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
|
||||
// Health endpoint
|
||||
healthHandler := health.Handler().WithJSON(true)
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
|
||||
return c.Status(statusCode).Send(body)
|
||||
})
|
||||
// Everything else falls back on static content
|
||||
app.Use(redirect.New(redirect.Config{
|
||||
Rules: map[string]string{
|
||||
"/index.html": "/",
|
||||
},
|
||||
StatusCode: 301,
|
||||
}))
|
||||
staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
app.Use("/", fiberfs.New(fiberfs.Config{
|
||||
Root: http.FS(staticFileSystem),
|
||||
Index: "index.html",
|
||||
Browse: true,
|
||||
}))
|
||||
return app
|
||||
}
|
97
api/api_test.go
Normal file
97
api/api_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
WithSecurity bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "health",
|
||||
Path: "/health",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Path: "/metrics",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "favicon.ico",
|
||||
Path: "/favicon.ico",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "app.js",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "app.js-gzipped",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "chunk-vendors.js",
|
||||
Path: "/js/chunk-vendors.js",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chunk-vendors.js-gzipped",
|
||||
Path: "/js/chunk-vendors.js",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "index",
|
||||
Path: "/",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "index-html-redirect",
|
||||
Path: "/index.html",
|
||||
ExpectedCode: fiber.StatusMovedPermanently,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg := &config.Config{Metrics: true, UI: &ui.Config{}}
|
||||
if scenario.WithSecurity {
|
||||
cfg.Security = &security.Config{
|
||||
Basic: &security.BasicConfig{
|
||||
Username: "john.doe",
|
||||
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
|
||||
},
|
||||
}
|
||||
}
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
309
api/badge.go
Normal file
309
api/badge.go
Normal file
@ -0,0 +1,309 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
badgeColorHexAwesome = "#40cc11"
|
||||
badgeColorHexGreat = "#94cc11"
|
||||
badgeColorHexGood = "#ccd311"
|
||||
badgeColorHexPassable = "#ccb311"
|
||||
badgeColorHexBad = "#cc8111"
|
||||
badgeColorHexVeryBad = "#c7130a"
|
||||
)
|
||||
|
||||
const (
|
||||
HealthStatusUp = "up"
|
||||
HealthStatusDown = "down"
|
||||
HealthStatusUnknown = "?"
|
||||
)
|
||||
|
||||
var (
|
||||
badgeColors = []string{badgeColorHexAwesome, badgeColorHexGreat, badgeColorHexGood, badgeColorHexPassable, badgeColorHexBad}
|
||||
)
|
||||
|
||||
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
//
|
||||
// Valid values for :duration -> 7d, 24h, 1h
|
||||
func UptimeBadge(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Add(-24 * time.Hour)
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
c.Set("Content-Type", "image/svg+xml")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send(generateUptimeBadgeSVG(duration, uptime))
|
||||
}
|
||||
|
||||
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
//
|
||||
// Valid values for :duration -> 7d, 24h, 1h
|
||||
func ResponseTimeBadge(config *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Add(-24 * time.Hour)
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
c.Set("Content-Type", "image/svg+xml")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config))
|
||||
}
|
||||
}
|
||||
|
||||
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
func HealthBadge(c *fiber.Ctx) error {
|
||||
key := c.Params("key")
|
||||
pagingConfig := paging.NewEndpointStatusParams()
|
||||
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
healthStatus := HealthStatusUnknown
|
||||
if len(status.Results) > 0 {
|
||||
if status.Results[0].Success {
|
||||
healthStatus = HealthStatusUp
|
||||
} else {
|
||||
healthStatus = HealthStatusDown
|
||||
}
|
||||
}
|
||||
c.Set("Content-Type", "image/svg+xml")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send(generateHealthBadgeSVG(healthStatus))
|
||||
}
|
||||
|
||||
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
||||
var labelWidth, valueWidth, valueWidthAdjustment int
|
||||
switch duration {
|
||||
case "7d":
|
||||
labelWidth = 65
|
||||
case "24h":
|
||||
labelWidth = 70
|
||||
case "1h":
|
||||
labelWidth = 65
|
||||
default:
|
||||
}
|
||||
color := getBadgeColorFromUptime(uptime)
|
||||
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", uptime*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
|
||||
}
|
||||
|
||||
func getBadgeColorFromUptime(uptime float64) string {
|
||||
if uptime >= 0.975 {
|
||||
return badgeColorHexAwesome
|
||||
} else if uptime >= 0.95 {
|
||||
return badgeColorHexGreat
|
||||
} else if uptime >= 0.9 {
|
||||
return badgeColorHexGood
|
||||
} else if uptime >= 0.8 {
|
||||
return badgeColorHexPassable
|
||||
} else if uptime >= 0.65 {
|
||||
return badgeColorHexBad
|
||||
}
|
||||
return badgeColorHexVeryBad
|
||||
}
|
||||
|
||||
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
|
||||
var labelWidth, valueWidth int
|
||||
switch duration {
|
||||
case "7d":
|
||||
labelWidth = 105
|
||||
case "24h":
|
||||
labelWidth = 110
|
||||
case "1h":
|
||||
labelWidth = 105
|
||||
default:
|
||||
}
|
||||
color := getBadgeColorFromResponseTime(averageResponseTime, key, cfg)
|
||||
sanitizedValue := strconv.Itoa(averageResponseTime) + "ms"
|
||||
valueWidth = len(sanitizedValue) * 11
|
||||
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">
|
||||
response time %s
|
||||
</text>
|
||||
<text x="%d" y="14">
|
||||
response time %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
|
||||
}
|
||||
|
||||
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
|
||||
endpoint := cfg.GetEndpointByKey(key)
|
||||
// the threshold config requires 5 values, so we can be sure it's set here
|
||||
for i := 0; i < 5; i++ {
|
||||
if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
|
||||
return badgeColors[i]
|
||||
}
|
||||
}
|
||||
return badgeColorHexVeryBad
|
||||
}
|
||||
|
||||
func generateHealthBadgeSVG(healthStatus string) []byte {
|
||||
var labelWidth, valueWidth int
|
||||
switch healthStatus {
|
||||
case HealthStatusUp:
|
||||
valueWidth = 28
|
||||
case HealthStatusDown:
|
||||
valueWidth = 44
|
||||
case HealthStatusUnknown:
|
||||
valueWidth = 10
|
||||
default:
|
||||
}
|
||||
color := getBadgeColorFromHealth(healthStatus)
|
||||
labelWidth = 48
|
||||
|
||||
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">
|
||||
health
|
||||
</text>
|
||||
<text x="%d" y="14">
|
||||
health
|
||||
</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, labelX, valueX, healthStatus, valueX, healthStatus))
|
||||
|
||||
return svg
|
||||
}
|
||||
|
||||
func getBadgeColorFromHealth(healthStatus string) string {
|
||||
if healthStatus == HealthStatusUp {
|
||||
return badgeColorHexAwesome
|
||||
} else if healthStatus == HealthStatusDown {
|
||||
return badgeColorHexVeryBad
|
||||
}
|
||||
return badgeColorHexPassable
|
||||
}
|
380
api/badge_test.go
Normal file
380
api/badge_test.go
Normal file
@ -0,0 +1,380 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/core/ui"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
)
|
||||
|
||||
func TestBadge(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "badge-uptime-1h",
|
||||
Path: "/api/v1/endpoints/core_frontend/uptimes/1h/badge.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-uptime-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/uptimes/24h/badge.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-uptime-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/uptimes/7d/badge.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-uptime-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/uptimes/3d/badge.svg",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "badge-uptime-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/uptimes/7d/badge.svg",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "badge-response-time-1h",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/1h/badge.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-response-time-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/badge.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-response-time-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d/badge.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-response-time-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/3d/badge.svg",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "badge-response-time-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "badge-health-up",
|
||||
Path: "/api/v1/endpoints/core_frontend/health/badge.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-health-down",
|
||||
Path: "/api/v1/endpoints/core_backend/health/badge.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-health-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBadgeColorFromUptime(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Uptime float64
|
||||
ExpectedColor string
|
||||
}{
|
||||
{
|
||||
Uptime: 1,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
Uptime: 0.99,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
Uptime: 0.97,
|
||||
ExpectedColor: badgeColorHexGreat,
|
||||
},
|
||||
{
|
||||
Uptime: 0.95,
|
||||
ExpectedColor: badgeColorHexGreat,
|
||||
},
|
||||
{
|
||||
Uptime: 0.93,
|
||||
ExpectedColor: badgeColorHexGood,
|
||||
},
|
||||
{
|
||||
Uptime: 0.9,
|
||||
ExpectedColor: badgeColorHexGood,
|
||||
},
|
||||
{
|
||||
Uptime: 0.85,
|
||||
ExpectedColor: badgeColorHexPassable,
|
||||
},
|
||||
{
|
||||
Uptime: 0.7,
|
||||
ExpectedColor: badgeColorHexBad,
|
||||
},
|
||||
{
|
||||
Uptime: 0.65,
|
||||
ExpectedColor: badgeColorHexBad,
|
||||
},
|
||||
{
|
||||
Uptime: 0.6,
|
||||
ExpectedColor: badgeColorHexVeryBad,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run("uptime-"+strconv.Itoa(int(scenario.Uptime*100)), func(t *testing.T) {
|
||||
if getBadgeColorFromUptime(scenario.Uptime) != scenario.ExpectedColor {
|
||||
t.Errorf("expected %s from %f, got %v", scenario.ExpectedColor, scenario.Uptime, getBadgeColorFromUptime(scenario.Uptime))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
|
||||
var (
|
||||
firstCondition = core.Condition("[STATUS] == 200")
|
||||
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
|
||||
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||
)
|
||||
|
||||
firstTestEndpoint := core.Endpoint{
|
||||
Name: "a",
|
||||
URL: "https://example.org/what/ever",
|
||||
Method: "GET",
|
||||
Body: "body",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
|
||||
Alerts: nil,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
UIConfig: ui.GetDefaultConfig(),
|
||||
}
|
||||
secondTestEndpoint := core.Endpoint{
|
||||
Name: "b",
|
||||
URL: "https://example.org/what/ever",
|
||||
Method: "GET",
|
||||
Body: "body",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
|
||||
Alerts: nil,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
UIConfig: &ui.Config{
|
||||
Badge: &ui.Badge{
|
||||
ResponseTime: &ui.ResponseTime{
|
||||
Thresholds: []int{100, 500, 1000, 2000, 3000},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
|
||||
}
|
||||
|
||||
testSuccessfulResult := core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: time.Now(),
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store.Get().Insert(&firstTestEndpoint, &testSuccessfulResult)
|
||||
store.Get().Insert(&secondTestEndpoint, &testSuccessfulResult)
|
||||
|
||||
scenarios := []struct {
|
||||
Key string
|
||||
ResponseTime int
|
||||
ExpectedColor string
|
||||
}{
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 10,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 50,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 75,
|
||||
ExpectedColor: badgeColorHexGreat,
|
||||
},
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 150,
|
||||
ExpectedColor: badgeColorHexGreat,
|
||||
},
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 201,
|
||||
ExpectedColor: badgeColorHexGood,
|
||||
},
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 300,
|
||||
ExpectedColor: badgeColorHexGood,
|
||||
},
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 301,
|
||||
ExpectedColor: badgeColorHexPassable,
|
||||
},
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 450,
|
||||
ExpectedColor: badgeColorHexPassable,
|
||||
},
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 700,
|
||||
ExpectedColor: badgeColorHexBad,
|
||||
},
|
||||
{
|
||||
Key: firstTestEndpoint.Key(),
|
||||
ResponseTime: 1500,
|
||||
ExpectedColor: badgeColorHexVeryBad,
|
||||
},
|
||||
{
|
||||
Key: secondTestEndpoint.Key(),
|
||||
ResponseTime: 50,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
Key: secondTestEndpoint.Key(),
|
||||
ResponseTime: 1500,
|
||||
ExpectedColor: badgeColorHexPassable,
|
||||
},
|
||||
{
|
||||
Key: secondTestEndpoint.Key(),
|
||||
ResponseTime: 2222,
|
||||
ExpectedColor: badgeColorHexBad,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Key+"-response-time-"+strconv.Itoa(scenario.ResponseTime), func(t *testing.T) {
|
||||
if getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg) != scenario.ExpectedColor {
|
||||
t.Errorf("expected %s from %d, got %v", scenario.ExpectedColor, scenario.ResponseTime, getBadgeColorFromResponseTime(scenario.ResponseTime, scenario.Key, cfg))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBadgeColorFromHealth(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
HealthStatus string
|
||||
ExpectedColor string
|
||||
}{
|
||||
{
|
||||
HealthStatus: HealthStatusUp,
|
||||
ExpectedColor: badgeColorHexAwesome,
|
||||
},
|
||||
{
|
||||
HealthStatus: HealthStatusDown,
|
||||
ExpectedColor: badgeColorHexVeryBad,
|
||||
},
|
||||
{
|
||||
HealthStatus: HealthStatusUnknown,
|
||||
ExpectedColor: badgeColorHexPassable,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run("health-"+scenario.HealthStatus, func(t *testing.T) {
|
||||
if getBadgeColorFromHealth(scenario.HealthStatus) != scenario.ExpectedColor {
|
||||
t.Errorf("expected %s from %s, got %v", scenario.ExpectedColor, scenario.HealthStatus, getBadgeColorFromHealth(scenario.HealthStatus))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
15
api/cache.go
Normal file
15
api/cache.go
Normal file
@ -0,0 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gocache/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
||||
)
|
118
api/chart.go
Normal file
118
api/chart.go
Normal file
@ -0,0 +1,118 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
const timeFormat = "3:04PM"
|
||||
|
||||
var (
|
||||
gridStyle = chart.Style{
|
||||
StrokeColor: drawing.Color{R: 119, G: 119, B: 119, A: 40},
|
||||
StrokeWidth: 1.0,
|
||||
}
|
||||
axisStyle = chart.Style{
|
||||
FontColor: drawing.Color{R: 119, G: 119, B: 119, A: 255},
|
||||
}
|
||||
transparentStyle = chart.Style{
|
||||
FillColor: drawing.Color{R: 255, G: 255, B: 255, A: 0},
|
||||
}
|
||||
)
|
||||
|
||||
func ResponseTimeChart(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
from = time.Now().Truncate(time.Hour).Add(-24 * 7 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 7d, 24h")
|
||||
}
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
if len(hourlyAverageResponseTime) == 0 {
|
||||
return c.Status(204).SendString("")
|
||||
}
|
||||
series := chart.TimeSeries{
|
||||
Name: "Average response time per hour",
|
||||
Style: chart.Style{
|
||||
StrokeWidth: 1.5,
|
||||
DotWidth: 2.0,
|
||||
},
|
||||
}
|
||||
keys := make([]int, 0, len(hourlyAverageResponseTime))
|
||||
earliestTimestamp := int64(0)
|
||||
for hourlyTimestamp := range hourlyAverageResponseTime {
|
||||
keys = append(keys, int(hourlyTimestamp))
|
||||
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
|
||||
earliestTimestamp = hourlyTimestamp
|
||||
}
|
||||
}
|
||||
for earliestTimestamp > from.Unix() {
|
||||
earliestTimestamp -= int64(time.Hour.Seconds())
|
||||
keys = append(keys, int(earliestTimestamp))
|
||||
}
|
||||
sort.Ints(keys)
|
||||
var maxAverageResponseTime float64
|
||||
for _, key := range keys {
|
||||
averageResponseTime := float64(hourlyAverageResponseTime[int64(key)])
|
||||
if maxAverageResponseTime < averageResponseTime {
|
||||
maxAverageResponseTime = averageResponseTime
|
||||
}
|
||||
series.XValues = append(series.XValues, time.Unix(int64(key), 0))
|
||||
series.YValues = append(series.YValues, averageResponseTime)
|
||||
}
|
||||
graph := chart.Chart{
|
||||
Canvas: transparentStyle,
|
||||
Background: transparentStyle,
|
||||
Width: 1280,
|
||||
Height: 300,
|
||||
XAxis: chart.XAxis{
|
||||
ValueFormatter: chart.TimeValueFormatterWithFormat(timeFormat),
|
||||
GridMajorStyle: gridStyle,
|
||||
GridMinorStyle: gridStyle,
|
||||
Style: axisStyle,
|
||||
NameStyle: axisStyle,
|
||||
},
|
||||
YAxis: chart.YAxis{
|
||||
Name: "Average response time",
|
||||
GridMajorStyle: gridStyle,
|
||||
GridMinorStyle: gridStyle,
|
||||
Style: axisStyle,
|
||||
NameStyle: axisStyle,
|
||||
Range: &chart.ContinuousRange{
|
||||
Min: 0,
|
||||
Max: math.Ceil(maxAverageResponseTime * 1.25),
|
||||
},
|
||||
},
|
||||
Series: []chart.Series{series},
|
||||
}
|
||||
c.Set("Content-Type", "image/svg+xml")
|
||||
c.Set("Cache-Control", "no-cache, no-store")
|
||||
c.Set("Expires", "0")
|
||||
c.Status(http.StatusOK)
|
||||
if err := graph.Render(chart.SVG, c); err != nil {
|
||||
log.Println("[api][ResponseTimeChart] Failed to render response time chart:", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
78
api/chart_test.go
Normal file
78
api/chart_test.go
Normal file
@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
)
|
||||
|
||||
func TestResponseTimeChart(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "chart-response-time-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/response-times/7d/chart.svg",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
25
api/config.go
Normal file
25
api/config.go
Normal file
@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type ConfigHandler struct {
|
||||
securityConfig *security.Config
|
||||
}
|
||||
|
||||
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||
hasOIDC := false
|
||||
isAuthenticated := true // Default to true if no security config is set
|
||||
if handler.securityConfig != nil {
|
||||
hasOIDC = handler.securityConfig.OIDC != nil
|
||||
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
|
||||
}
|
||||
// Return the config
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).
|
||||
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
|
||||
}
|
46
api/config_test.go
Normal file
46
api/config_test.go
Normal file
@ -0,0 +1,46 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestConfigHandler_ServeHTTP(t *testing.T) {
|
||||
securityConfig := &security.Config{
|
||||
OIDC: &security.OIDCConfig{
|
||||
IssuerURL: "https://sso.gatus.io/",
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
},
|
||||
}
|
||||
handler := ConfigHandler{securityConfig: securityConfig}
|
||||
// Create a fake router. We're doing this because I need the gate to be initialized.
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/config", handler.GetConfig)
|
||||
err := securityConfig.ApplySecurityMiddleware(app)
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
// Test the config handler
|
||||
request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Error("expected code to be 200, but was", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
if string(body) != `{"oidc":true,"authenticated":false}` {
|
||||
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
|
||||
}
|
||||
}
|
107
api/endpoint_status.go
Normal file
107
api/endpoint_status.go
Normal file
@ -0,0 +1,107 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/remote"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// EndpointStatuses handles requests to retrieve all EndpointStatus
|
||||
// Due to how intensive this operation can be on the storage, this function leverages a cache.
|
||||
func EndpointStatuses(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c)
|
||||
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
|
||||
var data []byte
|
||||
if !exists {
|
||||
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
|
||||
if err != nil {
|
||||
log.Printf("[api][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
// ALPHA: Retrieve endpoint statuses from remote instances
|
||||
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
|
||||
log.Printf("[handler][EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
|
||||
} else if endpointStatusesFromRemote != nil {
|
||||
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
|
||||
}
|
||||
// Marshal endpoint statuses to JSON
|
||||
data, err = json.Marshal(endpointStatuses)
|
||||
if err != nil {
|
||||
log.Printf("[api][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
|
||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||
}
|
||||
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
|
||||
} else {
|
||||
data = value.([]byte)
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).Send(data)
|
||||
}
|
||||
}
|
||||
|
||||
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) {
|
||||
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var endpointStatusesFromAllRemotes []*core.EndpointStatus
|
||||
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
|
||||
for _, instance := range remoteConfig.Instances {
|
||||
response, err := httpClient.Get(instance.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
_ = response.Body.Close()
|
||||
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
continue
|
||||
}
|
||||
var endpointStatuses []*core.EndpointStatus
|
||||
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
|
||||
_ = response.Body.Close()
|
||||
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
continue
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
for _, endpointStatus := range endpointStatuses {
|
||||
endpointStatus.Name = instance.EndpointPrefix + endpointStatus.Name
|
||||
}
|
||||
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
|
||||
}
|
||||
return endpointStatusesFromAllRemotes, nil
|
||||
}
|
||||
|
||||
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
|
||||
func EndpointStatus(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c)
|
||||
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
log.Printf("[api][EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
if endpointStatus == nil { // XXX: is this check necessary?
|
||||
log.Printf("[api][EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
|
||||
return c.Status(404).SendString("not found")
|
||||
}
|
||||
output, err := json.Marshal(endpointStatus)
|
||||
if err != nil {
|
||||
log.Printf("[api][EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).Send(output)
|
||||
}
|
220
api/endpoint_status_test.go
Normal file
220
api/endpoint_status_test.go
Normal file
@ -0,0 +1,220 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
)
|
||||
|
||||
var (
|
||||
timestamp = time.Now()
|
||||
|
||||
testEndpoint = core.Endpoint{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
URL: "https://example.org/what/ever",
|
||||
Method: "GET",
|
||||
Body: "body",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
|
||||
Alerts: nil,
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
testSuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: timestamp,
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
testUnsuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: []string{"error-1", "error-2"},
|
||||
Connected: true,
|
||||
Success: false,
|
||||
Timestamp: timestamp,
|
||||
Duration: 750 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: false,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestEndpointStatus(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "endpoint-status",
|
||||
Path: "/api/v1/endpoints/core_frontend/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "endpoint-status-gzip",
|
||||
Path: "/api/v1/endpoints/core_frontend/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "endpoint-status-pagination",
|
||||
Path: "/api/v1/endpoints/core_frontend/statuses?page=1&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "endpoint-status-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/statuses",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointStatuses(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
firstResult := &testSuccessfulResult
|
||||
secondResult := &testUnsuccessfulResult
|
||||
store.Get().Insert(&testEndpoint, firstResult)
|
||||
store.Get().Insert(&testEndpoint, secondResult)
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
api := New(&config.Config{Metrics: true})
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
ExpectedBody string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "no-pagination",
|
||||
Path: "/api/v1/endpoints/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"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"}]}]`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-first-result",
|
||||
Path: "/api/v1/endpoints/statuses?page=1&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"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"}]}]`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-second-result",
|
||||
Path: "/api/v1/endpoints/statuses?page=2&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"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"}]}]`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-no-results",
|
||||
Path: "/api/v1/endpoints/statuses?page=5&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[]}]`,
|
||||
},
|
||||
{
|
||||
Name: "invalid-pagination-should-fall-back-to-default",
|
||||
Path: "/api/v1/endpoints/statuses?page=INVALID&pageSize=INVALID",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"name","group":"group","key":"group_name","results":[{"status":200,"hostname":"example.org","duration":150000000,"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"}]}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
30
api/spa.go
Normal file
30
api/spa.go
Normal file
@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func SinglePageApplication(ui *ui.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
|
||||
if err != nil {
|
||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||
log.Println("[api][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error())
|
||||
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
||||
}
|
||||
c.Set("Content-Type", "text/html")
|
||||
err = t.Execute(c, ui)
|
||||
if err != nil {
|
||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||
log.Println("[api][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error())
|
||||
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
||||
}
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
}
|
79
api/spa_test.go
Normal file
79
api/spa_test.go
Normal file
@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
)
|
||||
|
||||
func TestSinglePageApplication(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*core.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
UI: &ui.Config{
|
||||
Title: "example-title",
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "frontend-endpoint",
|
||||
Path: "/endpoints/core_frontend",
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if !strings.Contains(string(body), cfg.UI.Title) {
|
||||
t.Errorf("%s %s should have contained the title", request.Method, request.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
48
api/util.go
Normal file
48
api/util.go
Normal file
@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultPage is the default page to use if none is specified or an invalid value is provided
|
||||
DefaultPage = 1
|
||||
|
||||
// DefaultPageSize is the default page siZE to use if none is specified or an invalid value is provided
|
||||
DefaultPageSize = 20
|
||||
|
||||
// MaximumPageSize is the maximum page size allowed
|
||||
MaximumPageSize = common.MaximumNumberOfResults
|
||||
)
|
||||
|
||||
func extractPageAndPageSizeFromRequest(c *fiber.Ctx) (page, pageSize int) {
|
||||
var err error
|
||||
if pageParameter := c.Query("page"); len(pageParameter) == 0 {
|
||||
page = DefaultPage
|
||||
} else {
|
||||
page, err = strconv.Atoi(pageParameter)
|
||||
if err != nil {
|
||||
page = DefaultPage
|
||||
}
|
||||
if page < 1 {
|
||||
page = DefaultPage
|
||||
}
|
||||
}
|
||||
if pageSizeParameter := c.Query("pageSize"); len(pageSizeParameter) == 0 {
|
||||
pageSize = DefaultPageSize
|
||||
} else {
|
||||
pageSize, err = strconv.Atoi(pageSizeParameter)
|
||||
if err != nil {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaximumPageSize {
|
||||
pageSize = MaximumPageSize
|
||||
} else if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
73
api/util_test.go
Normal file
73
api/util_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Page string
|
||||
PageSize string
|
||||
ExpectedPage int
|
||||
ExpectedPageSize int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "20",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: 20,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
},
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "999999",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: MaximumPageSize,
|
||||
},
|
||||
{
|
||||
Page: "-1",
|
||||
PageSize: "-1",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
},
|
||||
{
|
||||
Page: "invalid",
|
||||
PageSize: "invalid",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) {
|
||||
//request := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), http.NoBody)
|
||||
app := fiber.New()
|
||||
c := app.AcquireCtx(&fasthttp.RequestCtx{})
|
||||
defer app.ReleaseCtx(c)
|
||||
c.Request().SetRequestURI(fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize))
|
||||
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c)
|
||||
if actualPage != scenario.ExpectedPage {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
|
||||
}
|
||||
if actualPageSize != scenario.ExpectedPageSize {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPageSize, actualPageSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user