mirror of
https://github.com/TwiN/gatus.git
synced 2024-11-25 01:13:40 +01:00
feat(badge): Implement UP/DOWN status badge (#291)
* Implement status badge endpoint * Update integration tests for status badge generation * Add status badge in the UI * Update static assets * Update README with status badge description * Rename constants to pascal-case * Check for success of the endpoint conditions * Rename status badge to health badge
This commit is contained in:
parent
0193a200b8
commit
a3e35c862c
18
README.md
18
README.md
@ -78,6 +78,7 @@ Have any feedback or want to share your good/bad experience with Gatus? Feel fre
|
|||||||
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
|
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
|
||||||
- [Badges](#badges)
|
- [Badges](#badges)
|
||||||
- [Uptime](#uptime)
|
- [Uptime](#uptime)
|
||||||
|
- [Health](#health)
|
||||||
- [Response time](#response-time)
|
- [Response time](#response-time)
|
||||||
- [API](#api)
|
- [API](#api)
|
||||||
- [High level design overview](#high-level-design-overview)
|
- [High level design overview](#high-level-design-overview)
|
||||||
@ -1407,6 +1408,23 @@ Example:
|
|||||||
If you'd like to see a visual example of each badge available, you can simply navigate to the endpoint's detail page.
|
If you'd like to see a visual example of each badge available, you can simply navigate to the endpoint's detail page.
|
||||||
|
|
||||||
|
|
||||||
|
#### Health
|
||||||
|
![Health](https://status.twin.sh/api/v1/endpoints/core_blog-external/health/badge.svg)
|
||||||
|
|
||||||
|
The path to generate a badge is the following:
|
||||||
|
```
|
||||||
|
/api/v1/endpoints/{key}/health/badge.svg
|
||||||
|
```
|
||||||
|
Where:
|
||||||
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||||
|
|
||||||
|
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
|
||||||
|
the URL would look like this:
|
||||||
|
```
|
||||||
|
https://example.com/api/v1/endpoints/core_frontend/health/badge.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
#### Response time
|
#### Response time
|
||||||
![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg)
|
![Response time 1h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/1h/badge.svg)
|
||||||
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
|
![Response time 24h](https://status.twin.sh/api/v1/endpoints/core_blog-external/response-times/24h/badge.svg)
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v3/storage/store"
|
"github.com/TwiN/gatus/v3/storage/store"
|
||||||
"github.com/TwiN/gatus/v3/storage/store/common"
|
"github.com/TwiN/gatus/v3/storage/store/common"
|
||||||
|
"github.com/TwiN/gatus/v3/storage/store/common/paging"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,6 +22,12 @@ const (
|
|||||||
badgeColorHexVeryBad = "#c7130a"
|
badgeColorHexVeryBad = "#c7130a"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
HealthStatusUp = "up"
|
||||||
|
HealthStatusDown = "down"
|
||||||
|
HealthStatusUnknown = "?"
|
||||||
|
)
|
||||||
|
|
||||||
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
//
|
//
|
||||||
// Valid values for {duration}: 7d, 24h, 1h
|
// Valid values for {duration}: 7d, 24h, 1h
|
||||||
@ -95,6 +102,37 @@ func ResponseTimeBadge(writer http.ResponseWriter, request *http.Request) {
|
|||||||
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime))
|
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
|
func HealthBadge(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
variables := mux.Vars(request)
|
||||||
|
key := variables["key"]
|
||||||
|
pagingConfig := paging.NewEndpointStatusParams()
|
||||||
|
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||||
|
if err != nil {
|
||||||
|
if err == common.ErrEndpointNotFound {
|
||||||
|
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||||
|
} else if err == common.ErrInvalidTimeRange {
|
||||||
|
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||||
|
} else {
|
||||||
|
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
healthStatus := HealthStatusUnknown
|
||||||
|
if len(status.Results) > 0 {
|
||||||
|
if status.Results[0].Success {
|
||||||
|
healthStatus = HealthStatusUp
|
||||||
|
} else {
|
||||||
|
healthStatus = HealthStatusDown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
writer.Header().Set("Expires", "0")
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = writer.Write(generateHealthBadgeSVG(healthStatus))
|
||||||
|
}
|
||||||
|
|
||||||
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
||||||
var labelWidth, valueWidth, valueWidthAdjustment int
|
var labelWidth, valueWidth, valueWidthAdjustment int
|
||||||
switch duration {
|
switch duration {
|
||||||
@ -223,3 +261,61 @@ func getBadgeColorFromResponseTime(responseTime int) string {
|
|||||||
}
|
}
|
||||||
return badgeColorHexVeryBad
|
return badgeColorHexVeryBad
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateHealthBadgeSVG(healthStatus string) []byte {
|
||||||
|
var labelWidth, valueWidth int
|
||||||
|
switch healthStatus {
|
||||||
|
case HealthStatusUp:
|
||||||
|
valueWidth = 18
|
||||||
|
case HealthStatusDown:
|
||||||
|
valueWidth = 36
|
||||||
|
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">
|
||||||
|
status
|
||||||
|
</text>
|
||||||
|
<text x="%d" y="14">
|
||||||
|
status
|
||||||
|
</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
|
||||||
|
}
|
||||||
|
@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/TwiN/gatus/v3/watchdog"
|
"github.com/TwiN/gatus/v3/watchdog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUptimeBadge(t *testing.T) {
|
func TestBadge(t *testing.T) {
|
||||||
defer store.Get().Clear()
|
defer store.Get().Clear()
|
||||||
defer cache.Clear()
|
defer cache.Clear()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
@ -29,8 +29,8 @@ func TestUptimeBadge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
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, Duration: time.Second, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||||
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
|
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
|
||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
Name string
|
Name string
|
||||||
@ -89,6 +89,21 @@ func TestUptimeBadge(t *testing.T) {
|
|||||||
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
|
Path: "/api/v1/endpoints/invalid_key/response-times/7d/badge.svg",
|
||||||
ExpectedCode: http.StatusNotFound,
|
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",
|
Name: "chart-response-time-24h",
|
||||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
||||||
@ -219,3 +234,30 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -30,6 +30,7 @@ func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig
|
|||||||
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: securityConfig}).Methods("GET")
|
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: securityConfig}).Methods("GET")
|
||||||
protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
|
protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
|
||||||
protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET")
|
protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET")
|
||||||
|
unprotected.HandleFunc("/v1/endpoints/{key}/health/badge.svg", HealthBadge).Methods("GET")
|
||||||
unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
|
unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
|
||||||
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
|
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge).Methods("GET")
|
||||||
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||||
|
@ -20,6 +20,10 @@
|
|||||||
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">UPTIME</h1>
|
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">UPTIME</h1>
|
||||||
<hr/>
|
<hr/>
|
||||||
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
|
<div class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="text-sm text-gray-400 mb-1">Now</h2>
|
||||||
|
<img :src="generateHealthBadgeImageURL()" alt="health badge" class="mx-auto"/>
|
||||||
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
|
<h2 class="text-sm text-gray-400 mb-1">Last 7 days</h2>
|
||||||
<img :src="generateUptimeBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto"/>
|
<img :src="generateUptimeBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto"/>
|
||||||
@ -149,6 +153,9 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
generateHealthBadgeImageURL() {
|
||||||
|
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/health/badge.svg`;
|
||||||
|
},
|
||||||
generateUptimeBadgeImageURL(duration) {
|
generateUptimeBadgeImageURL(duration) {
|
||||||
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/uptimes/${duration}/badge.svg`;
|
return `${this.serverUrl}/api/v1/endpoints/${this.endpointStatus.key}/uptimes/${duration}/badge.svg`;
|
||||||
},
|
},
|
||||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user