Compare commits

..

13 Commits

Author SHA1 Message Date
TwinProduction
d65cebb1fb Remove Uptime.Last* parameters 2021-08-13 01:25:50 -04:00
TwinProduction
0b6fc6b520 Add GetUptimeByKey to store interface 2021-08-13 01:25:50 -04:00
TwinProduction
968b960283 Add memory.db to .gitignore 2021-08-13 01:25:50 -04:00
TwinProduction
77ba2169cf Close #125: Add more uptime badge colors 2021-08-11 21:05:51 -04:00
TwinProduction
f6c32a90ac Add small comment 2021-08-10 19:58:19 -04:00
TwinProduction
932a67d9e7 Update list of sponsors 2021-08-09 19:03:07 -04:00
TwinProduction
ee414df03f Add missing comment 2021-08-08 20:16:12 -04:00
TwinProduction
718f8260bb Register missing struct 2021-08-07 12:22:39 -04:00
TwinProduction
3cbe068fc1 Rename storage type inmemory to memory
This is technically a breaking change, but given how long ago this field was implemented as well as the fact that this is the default value if the type is not specified, I doubt anybody's explicitly setting it as inmemory
2021-08-07 12:11:35 -04:00
TwinProduction
4ada6ee7c9 Remove unneeded constants 2021-08-07 11:54:22 -04:00
TwinProduction
1e28905c8d Add clarification on hack 2021-08-07 11:46:58 -04:00
TwinProduction
4dbde07b85 Fix typo 2021-07-30 18:57:43 -04:00
TwinProduction
8f35679299 Improve documentation formatting 2021-07-30 18:56:05 -04:00
32 changed files with 331 additions and 301 deletions

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ gatus
db.db
config/config.yml
db.db-shm
db.db-wal
db.db-wal
memory.db

View File

@@ -68,7 +68,7 @@ For more details, see [Usage](#usage)
- [Service groups](#service-groups)
- [Exposing Gatus on a custom port](#exposing-gatus-on-a-custom-port)
- [Uptime Badges (ALPHA)](#uptime-badges)
- [API](#API)
- [API](#api)
- [High level design overview](#high-level-design-overview)
- [Sponsors](#sponsors)
@@ -141,18 +141,18 @@ If you want to test it locally, see [Docker](#docker).
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `debug` | Whether to enable debug logs. | `false` |
| `metrics` | Whether to expose metrics at /metrics. | `false` |
| `storage` | Storage configuration. See [Storage](#storage). | `{}` |
| `storage` | Storage configuration. <br />See [Storage](#storage). | `{}` |
| `services` | List of services to monitor. | Required `[]` |
| `services[].name` | Name of the service. Can be anything. | Required `""` |
| `services[].group` | Group name. Used to group multiple services together on the dashboard. See [Service groups](#service-groups). | `""` |
| `services[].group` | Group name. Used to group multiple services together on the dashboard. <br />See [Service groups](#service-groups). | `""` |
| `services[].url` | URL to send the request to. | Required `""` |
| `services[].method` | Request method. | `GET` |
| `services[].conditions` | Conditions used to determine the health of the service. See [Conditions](#conditions). | `[]` |
| `services[].conditions` | Conditions used to determine the health of the service. <br />See [Conditions](#conditions). | `[]` |
| `services[].interval` | Duration to wait between every status check. | `60s` |
| `services[].graphql` | Whether to wrap the body in a query param (`{"query":"$body"}`). | `false` |
| `services[].body` | Request body. | `""` |
| `services[].headers` | Request headers. | `{}` |
| `services[].dns` | Configuration for a service of type DNS. See [Monitoring a service using DNS queries](#monitoring-a-service-using-dns-queries). | `""` |
| `services[].dns` | Configuration for a service of type DNS. <br />See [Monitoring a service using DNS queries](#monitoring-a-service-using-dns-queries). | `""` |
| `services[].dns.query-type` | Query type for DNS service. | `""` |
| `services[].dns.query-name` | Query name for DNS service. | `""` |
| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `discord`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` |
@@ -161,14 +161,14 @@ If you want to test it locally, see [Docker](#docker).
| `services[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `services[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `services[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `services[].client` | Client configuration. See [Client configuration](#client-configuration). | `{}` |
| `alerting` | Configuration for alerting. See [Alerting](#alerting). | `{}` |
| `services[].client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting` | Configuration for alerting. <br />See [Alerting](#alerting). | `{}` |
| `security` | Security configuration. | `{}` |
| `security.basic` | Basic authentication security configuration. | `{}` |
| `security.basic.username` | Username for Basic authentication. | Required `""` |
| `security.basic.password-sha512` | Password's SHA512 hash for Basic authentication. | Required `""` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update. <br />See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
| `web` | Web configuration. | `{}` |
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
@@ -229,10 +229,10 @@ Here are some examples of conditions you can use:
| Parameter | Description | Default |
|:------------------ |:-------------------------------------------------------------------------------------- |:-------------- |
| `storage` | Storage configuration | `{}` |
| `storage.file` | File to persist the data in. If the type is `inmemory`, data is persisted on interval. | `""` |
| `storage.type` | Type of storage. Valid types: `inmemory`, `sqlite`. | `"inmemory"` |
| `storage.file` | File to persist the data in. If the type is `memory`, data is persisted on interval. | `""` |
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`. | `"memory"` |
- If `storage.type` is `inmemory` (default) and `storage.file` is set to a non-blank value.
- If `storage.type` is `memory` (default) and `storage.file` is set to a non-blank value.
Furthermore, the data is periodically persisted, but everything remains in memory.
- If `storage.type` is `sqlite`, `storage.file` must not be blank.
```yaml
@@ -288,15 +288,15 @@ ignored.
| Parameter | Description | Default |
|:-----------------------|:---------------------------------------------------------------------------------------------------------------------- |:-------|
| `alerting.discord` | Configuration for alerts of type `discord`. See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`. See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
| `alerting.custom` | Configuration for custom actions on failure or alerts. See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
| `alerting.teams` | Configuration for alerts of type `teams`. <br />See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` |
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
#### Configuring Discord alerts
@@ -304,7 +304,7 @@ ignored.
|:---------------------------------------- |:-------------------------------------------- |:-------------- |
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
| `alerting.discord.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
@@ -328,12 +328,12 @@ services:
#### Configuring Mattermost alerts
| Parameter | Description | Default |
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
| `alerting.mattermost.client` | Client configuration. See [Client configuration](#client-configuration). | `{}` |
| `alerting.mattermost.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| Parameter | Description | Default |
|:----------------------------------- |:------------------------------------------------------------------------------------------- |:-------------- |
| `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` |
| `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` |
| `alerting.mattermost.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
```yaml
alerting:
@@ -363,13 +363,13 @@ Here's an example of what the notifications look like:
#### Configuring Messagebird alerts
| Parameter | Description | Default |
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
| `alerting.messagebird.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| Parameter | Description | Default |
|:-------------------------------------|:----------------------------------------------------------------------------- |:-------------- |
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
| `alerting.messagebird.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
Example of sending **SMS** text message alert using Messagebird:
```yaml
@@ -400,7 +400,7 @@ services:
|:---------------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key. | Required `""` |
| `alerting.pagerduty.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
It is highly recommended to set `services[].alerts[].send-on-resolved` to `true` for alerts
of type `pagerduty`, because unlike other alerts, the operation resulting from setting said
@@ -435,7 +435,7 @@ services:
|:-------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
@@ -472,7 +472,7 @@ Here's an example of what the notifications look like:
|:-------------------------------- |:----------------------------------------------------------------------------- |:-------------- |
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
| `alerting.teams.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
@@ -504,7 +504,7 @@ Here's an example of what the notifications look like:
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
@@ -538,7 +538,7 @@ Here's an example of what the notifications look like:
| `alerting.twilio.token` | Twilio auth token | Required `""` |
| `alerting.twilio.from` | Number to send Twilio alerts from | Required `""` |
| `alerting.twilio.to` | Number to send twilio alerts to | Required `""` |
| `alerting.twilio.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.twilio.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
@@ -573,8 +573,8 @@ services:
| `alerting.custom.method` | Request method | `GET` |
| `alerting.custom.body` | Custom alerting request body. | `""` |
| `alerting.custom.headers` | Custom alerting request headers | `{}` |
| `alerting.custom.client` | Client configuration. See [Client configuration](#client-configuration). | `{}` |
| `alerting.custom.default-alert` | Default alert configuration. See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.custom.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
| `alerting.custom.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
While they're called alerts, you can use this feature to call anything.
@@ -734,7 +734,7 @@ services:
| `kubernetes` | Kubernetes configuration | `{}` |
| `kubernetes.auto-discover` | Whether to enable auto discovery | `false` |
| `kubernetes.cluster-mode` | Cluster mode to use for authenticating. Supported values: `in`, `out` | Required `""` |
| `kubernetes.service-template` | Service template. See `services[]` in [Configuration](#configuration) | Required `nil` |
| `kubernetes.service-template` | Service template. <br />See `services[]` in [Configuration](#configuration) | Required `nil` |
| `kubernetes.excluded-service-suffixes` | List of service suffixes to not monitor (e.g. `canary`) | `[]` |
| `kubernetes.namespaces` | List of configurations for the namespaces from which services will be discovered | `[]` |
| `kubernetes.namespaces[].name` | Namespace name | Required `""` |
@@ -1092,6 +1092,7 @@ web:
port: ${PORT}
```
### Uptime badges
![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)
@@ -1127,6 +1128,7 @@ Example: ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/co
If you'd like to see a visual example of each badges available, you can simply navigate to the service's detail page.
### API
Gatus provides a simple read-only API which can be queried in order to programmatically determine service status and history.
@@ -1157,4 +1159,5 @@ No such header is required to query the API.
## Sponsors
You can find the full list of sponsors [here](https://github.com/sponsors/TwinProduction).
[<img src="https://github.com/math280h.png" width="35" />](https://github.com/math280h)
[<img src="https://github.com/math280h.png" width="50" />](https://github.com/math280h)
[<img src="https://github.com/pyroscope-io.png" width="50" />](https://github.com/pyroscope-io)

View File

@@ -177,7 +177,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
func validateStorageConfig(config *Config) error {
if config.Storage == nil {
config.Storage = &storage.Config{
Type: storage.TypeInMemory,
Type: storage.TypeMemory,
}
}
err := storage.Initialize(config.Storage)

View File

@@ -6,9 +6,8 @@ import (
"strings"
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/gorilla/mux"
)
@@ -19,22 +18,31 @@ import (
func badgeHandler(writer http.ResponseWriter, request *http.Request) {
variables := mux.Vars(request)
duration := variables["duration"]
if duration != "7d" && duration != "24h" && duration != "1h" {
var from time.Time
switch duration {
case "7d":
from = time.Now().Add(-time.Hour * 24 * 7)
case "24h":
from = time.Now().Add(-time.Hour * 24)
case "1h":
from = time.Now().Add(-time.Hour)
default:
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte("Durations supported: 7d, 24h, 1h"))
return
}
identifier := variables["identifier"]
key := strings.TrimSuffix(identifier, ".svg")
serviceStatus := storage.Get().GetServiceStatusByKey(key, paging.NewServiceStatusParams().WithUptime())
if serviceStatus == nil {
writer.WriteHeader(http.StatusNotFound)
_, _ = writer.Write([]byte("Requested service not found"))
return
}
if serviceStatus.Uptime == nil {
writer.WriteHeader(http.StatusInternalServerError)
_, _ = writer.Write([]byte("Failed to compute uptime"))
uptime, err := storage.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrServiceNotFound {
writer.WriteHeader(http.StatusNotFound)
} else if err == common.ErrInvalidTimeRange {
writer.WriteHeader(http.StatusBadRequest)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
_, _ = writer.Write([]byte(err.Error()))
return
}
formattedDate := time.Now().Format(http.TimeFormat)
@@ -42,31 +50,22 @@ func badgeHandler(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Date", formattedDate)
writer.Header().Set("Expires", formattedDate)
writer.Header().Set("Content-Type", "image/svg+xml")
_, _ = writer.Write(generateSVG(duration, serviceStatus.Uptime))
_, _ = writer.Write(generateSVG(duration, uptime))
}
func generateSVG(duration string, uptime *core.Uptime) []byte {
func generateSVG(duration string, uptime float64) []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"), ".") + "%"
color := getBadgeColorFromUptime(uptime)
sanitizedValue := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", uptime*100), "0"), ".") + "%"
if strings.Contains(sanitizedValue, ".") {
valueWidthAdjustment = -10
}
@@ -104,3 +103,18 @@ func generateSVG(duration string, uptime *core.Uptime) []byte {
</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 "#40cc11"
} else if uptime >= 0.95 {
return "#94cc11"
} else if uptime >= 0.9 {
return "#ccc311"
} else if uptime >= 0.8 {
return "#ccb311"
} else if uptime >= 0.5 {
return "#cc8111"
}
return "#c7130a"
}

32
controller/badge_test.go Normal file
View File

@@ -0,0 +1,32 @@
package controller
import (
"testing"
)
func TestGetBadgeColorFromUptime(t *testing.T) {
if getBadgeColorFromUptime(1) != "#40cc11" {
t.Error("expected #40cc11 from an uptime of 1, got", getBadgeColorFromUptime(1))
}
if getBadgeColorFromUptime(0.95) != "#94cc11" {
t.Error("expected #94cc11 from an uptime of 0.95, got", getBadgeColorFromUptime(0.95))
}
if getBadgeColorFromUptime(0.9) != "#ccc311" {
t.Error("expected #c9cc11 from an uptime of 0.9, got", getBadgeColorFromUptime(0.9))
}
if getBadgeColorFromUptime(0.85) != "#ccb311" {
t.Error("expected #ccb311 from an uptime of 0.85, got", getBadgeColorFromUptime(0.85))
}
if getBadgeColorFromUptime(0.75) != "#cc8111" {
t.Error("expected #cc8111 from an uptime of 0.75, got", getBadgeColorFromUptime(0.75))
}
if getBadgeColorFromUptime(0.6) != "#cc8111" {
t.Error("expected #cc8111 from an uptime of 0.6, got", getBadgeColorFromUptime(0.6))
}
if getBadgeColorFromUptime(0.25) != "#c7130a" {
t.Error("expected #c7130a from an uptime of 0.25, got", getBadgeColorFromUptime(0.25))
}
if getBadgeColorFromUptime(0) != "#c7130a" {
t.Error("expected #c7130a from an uptime of 0, got", getBadgeColorFromUptime(0))
}
}

View File

@@ -13,10 +13,10 @@ import (
"time"
"github.com/TwinProduction/gatus/config"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/security"
"github.com/TwinProduction/gatus/storage"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
"github.com/TwinProduction/gocache"
"github.com/TwinProduction/health"
"github.com/gorilla/mux"
@@ -138,20 +138,28 @@ func serviceStatusesHandler(writer http.ResponseWriter, r *http.Request) {
func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r)
vars := mux.Vars(r)
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, core.MaximumNumberOfEvents).WithUptime())
serviceStatus := storage.Get().GetServiceStatusByKey(vars["key"], paging.NewServiceStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
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
}
uptime7Days, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour*24*7), time.Now())
uptime24Hours, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour*24), time.Now())
uptime1Hour, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour), time.Now())
data := map[string]interface{}{
"serviceStatus": serviceStatus,
// The following fields, while present on core.ServiceStatus, are annotated to remain hidden so that we can
// expose only the necessary data on /api/v1/statuses.
// Since the /api/v1/statuses/{key} endpoint does need this data, however, we explicitly expose it here
"events": serviceStatus.Events,
"uptime": serviceStatus.Uptime,
// TODO: remove this in v3.0.0. Not used by front-end, only used for API. Left here for v2.x.x backward compatibility
"uptime": map[string]float64{
"7d": uptime7Days,
"24h": uptime24Hours,
"1h": uptime1Hour,
},
}
output, err := json.Marshal(data)
if err != nil {

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/common"
)
const (
@@ -15,7 +15,7 @@ const (
DefaultPageSize = 20
// MaximumPageSize is the maximum page size allowed
MaximumPageSize = core.MaximumNumberOfResults
MaximumPageSize = common.MaximumNumberOfResults
)
func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int) {

View File

@@ -1,13 +1,5 @@
package core
const (
// MaximumNumberOfResults is the maximum number of results that ServiceStatus.Results can have
MaximumNumberOfResults = 100
// MaximumNumberOfEvents is the maximum number of events that ServiceStatus.Events can have
MaximumNumberOfEvents = 50
)
// ServiceStatus contains the evaluation Results of a Service
type ServiceStatus struct {
// Name of the service
@@ -34,6 +26,10 @@ type ServiceStatus struct {
// We don't expose this through JSON, because the main dashboard doesn't need to have this data.
// However, the detailed service page does leverage this by including it to a map that will be
// marshalled alongside the ServiceStatus.
//
// TODO: Get rid of this in favor of using the new store.GetUptimeByKey.
// TODO: For memory, store the uptime in a different map? (is that possible, given that we need to persist it through gocache?)
// Deprecated
Uptime *Uptime `json:"-"`
}

View File

@@ -1,21 +1,8 @@
package core
import (
"time"
)
const (
numberOfHoursInTenDays = 10 * 24
sevenDays = 7 * 24 * time.Hour
)
// Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself
// and some other statistics
type Uptime struct {
LastSevenDays float64 `json:"7d"` // Uptime percentage over the past 7 days
LastTwentyFourHours float64 `json:"24h"` // Uptime percentage over the past 24 hours
LastHour float64 `json:"1h"` // Uptime percentage over the past hour
// SuccessfulExecutionsPerHour is a map containing the number of successes (value)
// for every hourly unix timestamps (key)
// Deprecated
@@ -27,6 +14,8 @@ type Uptime struct {
TotalExecutionsPerHour map[int64]uint64 `json:"-"`
// HourlyStatistics is a map containing metrics collected (value) for every hourly unix timestamps (key)
//
// Used only if the storage type is memory
HourlyStatistics map[int64]*HourlyUptimeStatistics `json:"-"`
}

View File

@@ -57,7 +57,7 @@ func Initialize(cfg *Config) error {
if err != nil {
return err
}
case TypeInMemory:
case TypeMemory:
fallthrough
default:
if len(cfg.File) > 0 {

View File

@@ -32,13 +32,13 @@ func TestInitialize(t *testing.T) {
ExpectedErr: nil,
},
{
Name: "inmemory-no-file",
Cfg: &Config{Type: TypeInMemory},
Name: "memory-no-file",
Cfg: &Config{Type: TypeMemory},
ExpectedErr: nil,
},
{
Name: "inmemory-with-file",
Cfg: &Config{Type: TypeInMemory, File: t.TempDir() + "/TestInitialize_inmemory-with-file.db"},
Name: "memory-with-file",
Cfg: &Config{Type: TypeMemory, File: t.TempDir() + "/TestInitialize_memory-with-file.db"},
ExpectedErr: nil,
},
{

View File

@@ -0,0 +1,8 @@
package common
import "errors"
var (
ErrServiceNotFound = errors.New("service not found") // When a service does not exist in the store
ErrInvalidTimeRange = errors.New("'from' cannot be older than 'to'") // When an invalid time range is provided
)

View File

@@ -0,0 +1,9 @@
package common
const (
// MaximumNumberOfResults is the maximum number of results that a service can have
MaximumNumberOfResults = 100
// MaximumNumberOfEvents is the maximum number of events that a service can have
MaximumNumberOfEvents = 50
)

View File

@@ -2,11 +2,10 @@ package paging
// ServiceStatusParams represents all parameters that can be used for paging purposes
type ServiceStatusParams struct {
EventsPage int // Number of the event page
EventsPageSize int // Size of the event page
ResultsPage int // Number of the result page
ResultsPageSize int // Size of the result page
IncludeUptime bool // Whether to include uptime data
EventsPage int // Number of the event page
EventsPageSize int // Size of the event page
ResultsPage int // Number of the result page
ResultsPageSize int // Size of the result page
}
// NewServiceStatusParams creates a new ServiceStatusParams
@@ -27,9 +26,3 @@ func (params *ServiceStatusParams) WithResults(page, pageSize int) *ServiceStatu
params.ResultsPageSize = pageSize
return params
}
// WithUptime sets the value IncludeUptime to true
func (params *ServiceStatusParams) WithUptime() *ServiceStatusParams {
params.IncludeUptime = true
return params
}

View File

@@ -10,7 +10,6 @@ func TestNewServiceStatusParams(t *testing.T) {
ExpectedEventsPageSize int
ExpectedResultsPage int
ExpectedResultsPageSize int
ExpectedIncludeUptime bool
}
scenarios := []Scenario{
{
@@ -20,7 +19,6 @@ func TestNewServiceStatusParams(t *testing.T) {
ExpectedEventsPageSize: 0,
ExpectedResultsPage: 0,
ExpectedResultsPageSize: 0,
ExpectedIncludeUptime: false,
},
{
Name: "with-events-page-2-size-7",
@@ -29,25 +27,22 @@ func TestNewServiceStatusParams(t *testing.T) {
ExpectedEventsPageSize: 7,
ExpectedResultsPage: 0,
ExpectedResultsPageSize: 0,
ExpectedIncludeUptime: false,
},
{
Name: "with-events-page-4-size-3-uptime",
Params: NewServiceStatusParams().WithEvents(4, 3).WithUptime(),
Params: NewServiceStatusParams().WithEvents(4, 3),
ExpectedEventsPage: 4,
ExpectedEventsPageSize: 3,
ExpectedResultsPage: 0,
ExpectedResultsPageSize: 0,
ExpectedIncludeUptime: true,
},
{
Name: "with-results-page-1-size-20-uptime",
Params: NewServiceStatusParams().WithResults(1, 20).WithUptime(),
Params: NewServiceStatusParams().WithResults(1, 20),
ExpectedEventsPage: 0,
ExpectedEventsPageSize: 0,
ExpectedResultsPage: 1,
ExpectedResultsPageSize: 20,
ExpectedIncludeUptime: true,
},
{
Name: "with-results-page-2-size-10-events-page-3-size-50",
@@ -56,7 +51,6 @@ func TestNewServiceStatusParams(t *testing.T) {
ExpectedEventsPageSize: 50,
ExpectedResultsPage: 2,
ExpectedResultsPageSize: 10,
ExpectedIncludeUptime: false,
},
}
for _, scenario := range scenarios {
@@ -73,9 +67,6 @@ func TestNewServiceStatusParams(t *testing.T) {
if scenario.Params.ResultsPageSize != scenario.ExpectedResultsPageSize {
t.Errorf("expected ResultsPageSize to be %d, was %d", scenario.ExpectedResultsPageSize, scenario.Params.ResultsPageSize)
}
if scenario.Params.IncludeUptime != scenario.ExpectedIncludeUptime {
t.Errorf("expected IncludeUptime to be %v, was %v", scenario.ExpectedIncludeUptime, scenario.Params.IncludeUptime)
}
})
}
}

View File

@@ -6,13 +6,15 @@ import (
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
"github.com/TwinProduction/gatus/util"
"github.com/TwinProduction/gocache"
)
func init() {
gob.Register(&core.ServiceStatus{})
gob.Register(&core.HourlyUptimeStatistics{})
gob.Register(&core.Uptime{})
gob.Register(&core.Result{})
gob.Register(&core.Event{})
@@ -68,6 +70,35 @@ func (s *Store) GetServiceStatusByKey(key string, params *paging.ServiceStatusPa
return ShallowCopyServiceStatus(serviceStatus.(*core.ServiceStatus), params)
}
// GetUptimeByKey returns the uptime percentage during a time range
func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) {
if from.After(to) {
return 0, common.ErrInvalidTimeRange
}
serviceStatus := s.cache.GetValue(key)
if serviceStatus == nil || serviceStatus.(*core.ServiceStatus).Uptime == nil {
return 0, common.ErrServiceNotFound
}
successfulExecutions := uint64(0)
totalExecutions := uint64(0)
current := from
for to.Sub(current) >= 0 {
hourlyUnixTimestamp := current.Truncate(time.Hour).Unix()
hourlyStats := serviceStatus.(*core.ServiceStatus).Uptime.HourlyStatistics[hourlyUnixTimestamp]
if hourlyStats == nil || hourlyStats.TotalExecutions == 0 {
current = current.Add(time.Hour)
continue
}
successfulExecutions += hourlyStats.SuccessfulExecutions
totalExecutions += hourlyStats.TotalExecutions
current = current.Add(time.Hour)
}
if totalExecutions == 0 {
return 0, nil
}
return float64(successfulExecutions) / float64(totalExecutions), nil
}
// Insert adds the observed result for the specified service into the store
func (s *Store) Insert(service *core.Service, result *core.Result) {
key := service.Key()

View File

@@ -5,7 +5,7 @@ import (
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/common/paging"
)
var (

View File

@@ -45,56 +45,6 @@ func processUptimeAfterResult(uptime *core.Uptime, result *core.Result) {
}
}
}
if result.Success {
// Recalculate uptime if at least one of the 1h, 24h or 7d uptime are not 100%
// If they're all 100%, then recalculating the uptime would be useless unless
// the result added was a failure (!result.Success)
if uptime.LastSevenDays != 1 || uptime.LastTwentyFourHours != 1 || uptime.LastHour != 1 {
recalculateUptime(uptime)
}
} else {
// Recalculate uptime if at least one of the 1h, 24h or 7d uptime are not 0%
// If they're all 0%, then recalculating the uptime would be useless unless
// the result added was a success (result.Success)
if uptime.LastSevenDays != 0 || uptime.LastTwentyFourHours != 0 || uptime.LastHour != 0 {
recalculateUptime(uptime)
}
}
}
func recalculateUptime(uptime *core.Uptime) {
uptimeBrackets := make(map[string]uint64)
now := time.Now()
// The oldest uptime bracket starts 7 days ago, so we'll start from there
timestamp := now.Add(-sevenDays)
for now.Sub(timestamp) >= 0 {
hourlyUnixTimestamp := timestamp.Truncate(time.Hour).Unix()
hourlyStats := uptime.HourlyStatistics[hourlyUnixTimestamp]
if hourlyStats == nil || hourlyStats.TotalExecutions == 0 {
timestamp = timestamp.Add(time.Hour)
continue
}
uptimeBrackets["7d_success"] += hourlyStats.SuccessfulExecutions
uptimeBrackets["7d_total"] += hourlyStats.TotalExecutions
if now.Sub(timestamp) <= 24*time.Hour {
uptimeBrackets["24h_success"] += hourlyStats.SuccessfulExecutions
uptimeBrackets["24h_total"] += hourlyStats.TotalExecutions
}
if now.Sub(timestamp) <= time.Hour {
uptimeBrackets["1h_success"] += hourlyStats.SuccessfulExecutions
uptimeBrackets["1h_total"] += hourlyStats.TotalExecutions
}
timestamp = timestamp.Add(time.Hour)
}
if uptimeBrackets["7d_total"] > 0 {
uptime.LastSevenDays = float64(uptimeBrackets["7d_success"]) / float64(uptimeBrackets["7d_total"])
}
if uptimeBrackets["24h_total"] > 0 {
uptime.LastTwentyFourHours = float64(uptimeBrackets["24h_success"]) / float64(uptimeBrackets["24h_total"])
}
if uptimeBrackets["1h_total"] > 0 {
uptime.LastHour = float64(uptimeBrackets["1h_success"]) / float64(uptimeBrackets["1h_total"])
}
}
// XXX: Remove this on v3.0.0

View File

@@ -12,22 +12,16 @@ func TestProcessUptimeAfterResult(t *testing.T) {
serviceStatus := core.NewServiceStatus(service.Key(), service.Group, service.Name)
uptime := serviceStatus.Uptime
checkUptimes(t, serviceStatus, 0.00, 0.00, 0.00)
now := time.Now()
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-7 * 24 * time.Hour), Success: true})
checkUptimes(t, serviceStatus, 1.00, 0.00, 0.00)
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-6 * 24 * time.Hour), Success: false})
checkUptimes(t, serviceStatus, 0.50, 0.00, 0.00)
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-8 * 24 * time.Hour), Success: true})
checkUptimes(t, serviceStatus, 0.50, 0.00, 0.00)
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-24 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-12 * time.Hour), Success: true})
checkUptimes(t, serviceStatus, 0.75, 1.00, 0.00)
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-1 * time.Hour), Success: true, Duration: 10 * time.Millisecond})
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 10, 1, 1)
@@ -37,7 +31,6 @@ func TestProcessUptimeAfterResult(t *testing.T) {
checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 535, 3, 1)
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-10 * time.Minute), Success: false})
checkUptimes(t, serviceStatus, 0.50, 0.50, 0.25)
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-120 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-119 * time.Hour), Success: true})
@@ -47,7 +40,6 @@ func TestProcessUptimeAfterResult(t *testing.T) {
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-8 * time.Hour), Success: true})
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-30 * time.Minute), Success: true})
processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-25 * time.Minute), Success: true})
checkUptimes(t, serviceStatus, 0.75, 0.70, 0.50)
}
func TestAddResultUptimeIsCleaningUpAfterItself(t *testing.T) {
@@ -62,29 +54,11 @@ func TestAddResultUptimeIsCleaningUpAfterItself(t *testing.T) {
if len(serviceStatus.Uptime.HourlyStatistics) > numberOfHoursInTenDays {
t.Errorf("At no point in time should there be more than %d entries in serviceStatus.SuccessfulExecutionsPerHour, but there are %d", numberOfHoursInTenDays, len(serviceStatus.Uptime.HourlyStatistics))
}
if now.Sub(timestamp) > time.Hour && serviceStatus.Uptime.LastHour != 0 {
t.Error("most recent timestamp > 1h ago, expected serviceStatus.Uptime.LastHour to be 0, got", serviceStatus.Uptime.LastHour)
}
if now.Sub(timestamp) < time.Hour && serviceStatus.Uptime.LastHour == 0 {
t.Error("most recent timestamp < 1h ago, expected serviceStatus.Uptime.LastHour to NOT be 0, got", serviceStatus.Uptime.LastHour)
}
// Simulate service with an interval of 3 minutes
timestamp = timestamp.Add(3 * time.Minute)
}
}
func checkUptimes(t *testing.T, status *core.ServiceStatus, expectedUptimeDuringLastSevenDays, expectedUptimeDuringLastTwentyFourHours, expectedUptimeDuringLastHour float64) {
if status.Uptime.LastSevenDays != expectedUptimeDuringLastSevenDays {
t.Errorf("expected status.Uptime.LastSevenDays to be %f, got %f", expectedUptimeDuringLastHour, status.Uptime.LastSevenDays)
}
if status.Uptime.LastTwentyFourHours != expectedUptimeDuringLastTwentyFourHours {
t.Errorf("expected status.Uptime.LastTwentyFourHours to be %f, got %f", expectedUptimeDuringLastTwentyFourHours, status.Uptime.LastTwentyFourHours)
}
if status.Uptime.LastHour != expectedUptimeDuringLastHour {
t.Errorf("expected status.Uptime.LastHour to be %f, got %f", expectedUptimeDuringLastHour, status.Uptime.LastHour)
}
}
func checkHourlyStatistics(t *testing.T, hourlyUptimeStatistics *core.HourlyUptimeStatistics, expectedTotalExecutionsResponseTime uint64, expectedTotalExecutions uint64, expectedSuccessfulExecutions uint64) {
if hourlyUptimeStatistics.TotalExecutionsResponseTime != expectedTotalExecutionsResponseTime {
t.Error("TotalExecutionsResponseTime should've been", expectedTotalExecutionsResponseTime, "got", hourlyUptimeStatistics.TotalExecutionsResponseTime)

View File

@@ -2,7 +2,8 @@ package memory
import (
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
)
// ShallowCopyServiceStatus returns a shallow copy of a ServiceStatus with only the results
@@ -28,11 +29,6 @@ func ShallowCopyServiceStatus(ss *core.ServiceStatus, params *paging.ServiceStat
} else {
shallowCopy.Events = ss.Events[eventsStart:eventsEnd]
}
if params.IncludeUptime {
shallowCopy.Uptime.LastHour = ss.Uptime.LastHour
shallowCopy.Uptime.LastTwentyFourHours = ss.Uptime.LastTwentyFourHours
shallowCopy.Uptime.LastSevenDays = ss.Uptime.LastSevenDays
}
return shallowCopy
}
@@ -63,11 +59,11 @@ func AddResult(ss *core.ServiceStatus, result *core.Result) {
// Check if there's any change since the last result
if ss.Results[len(ss.Results)-1].Success != result.Success {
ss.Events = append(ss.Events, core.NewEventFromResult(result))
if len(ss.Events) > core.MaximumNumberOfEvents {
if len(ss.Events) > common.MaximumNumberOfEvents {
// Doing ss.Events[1:] would usually be sufficient, but in the case where for some reason, the slice has
// more than one extra element, we can get rid of all of them at once and thus returning the slice to a
// length of MaximumNumberOfEvents by using ss.Events[len(ss.Events)-MaximumNumberOfEvents:] instead
ss.Events = ss.Events[len(ss.Events)-core.MaximumNumberOfEvents:]
ss.Events = ss.Events[len(ss.Events)-common.MaximumNumberOfEvents:]
}
}
} else {
@@ -75,11 +71,11 @@ func AddResult(ss *core.ServiceStatus, result *core.Result) {
ss.Events = append(ss.Events, core.NewEventFromResult(result))
}
ss.Results = append(ss.Results, result)
if len(ss.Results) > core.MaximumNumberOfResults {
if len(ss.Results) > common.MaximumNumberOfResults {
// Doing ss.Results[1:] would usually be sufficient, but in the case where for some reason, the slice has more
// than one extra element, we can get rid of all of them at once and thus returning the slice to a length of
// MaximumNumberOfResults by using ss.Results[len(ss.Results)-MaximumNumberOfResults:] instead
ss.Results = ss.Results[len(ss.Results)-core.MaximumNumberOfResults:]
ss.Results = ss.Results[len(ss.Results)-common.MaximumNumberOfResults:]
}
processUptimeAfterResult(ss.Uptime, result)
}

View File

@@ -4,13 +4,14 @@ import (
"testing"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
)
func BenchmarkShallowCopyServiceStatus(b *testing.B) {
service := &testService
serviceStatus := core.NewServiceStatus(service.Key(), service.Group, service.Name)
for i := 0; i < core.MaximumNumberOfResults; i++ {
for i := 0; i < common.MaximumNumberOfResults; i++ {
AddResult(serviceStatus, &testSuccessfulResult)
}
for n := 0; n < b.N; n++ {

View File

@@ -5,20 +5,21 @@ import (
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
)
func TestAddResult(t *testing.T) {
service := &core.Service{Name: "name", Group: "group"}
serviceStatus := core.NewServiceStatus(service.Key(), service.Group, service.Name)
for i := 0; i < (core.MaximumNumberOfResults+core.MaximumNumberOfEvents)*2; i++ {
for i := 0; i < (common.MaximumNumberOfResults+common.MaximumNumberOfEvents)*2; i++ {
AddResult(serviceStatus, &core.Result{Success: i%2 == 0, Timestamp: time.Now()})
}
if len(serviceStatus.Results) != core.MaximumNumberOfResults {
t.Errorf("expected serviceStatus.Results to not exceed a length of %d", core.MaximumNumberOfResults)
if len(serviceStatus.Results) != common.MaximumNumberOfResults {
t.Errorf("expected serviceStatus.Results to not exceed a length of %d", common.MaximumNumberOfResults)
}
if len(serviceStatus.Events) != core.MaximumNumberOfEvents {
t.Errorf("expected serviceStatus.Events to not exceed a length of %d", core.MaximumNumberOfEvents)
if len(serviceStatus.Events) != common.MaximumNumberOfEvents {
t.Errorf("expected serviceStatus.Events to not exceed a length of %d", common.MaximumNumberOfEvents)
}
// Try to add nil serviceStatus
AddResult(nil, &core.Result{Timestamp: time.Now()})
@@ -62,18 +63,4 @@ func TestShallowCopyServiceStatus(t *testing.T) {
if len(ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithResults(1, 50)).Results) != 25 {
t.Error("expected to have 25 results, because there's only 25 results")
}
uptime := ShallowCopyServiceStatus(serviceStatus, paging.NewServiceStatusParams().WithUptime()).Uptime
if uptime == nil {
t.Error("expected uptime to not be nil")
} else {
if uptime.LastHour != 1 {
t.Error("expected uptime.LastHour to not be 1, got", uptime.LastHour)
}
if uptime.LastTwentyFourHours != 0.5 {
t.Error("expected uptime.LastTwentyFourHours to not be 0.5, got", uptime.LastTwentyFourHours)
}
if uptime.LastSevenDays != 0.52 {
t.Error("expected uptime.LastSevenDays to not be 0.52, got", uptime.LastSevenDays)
}
}
}

View File

@@ -9,7 +9,8 @@ import (
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
"github.com/TwinProduction/gatus/util"
_ "modernc.org/sqlite"
)
@@ -19,11 +20,14 @@ import (
//////////////////////////////////////////////////////////////////////////////////////////////////
const (
// arraySeparator is the separator used to separate multiple strings in a single column.
// It's a dirty hack, but it's only used for persisting errors, and since this data will likely only ever be used
// for aesthetic purposes, I deemed it wasn't worth the performance impact of yet another one-to-many table.
arraySeparator = "|~|"
uptimeCleanUpThreshold = 10 * 24 * time.Hour // Maximum uptime age before triggering a clean up
eventsCleanUpThreshold = core.MaximumNumberOfEvents + 10 // Maximum number of events before triggering a clean up
resultsCleanUpThreshold = core.MaximumNumberOfResults + 10 // Maximum number of results before triggering a clean up
uptimeCleanUpThreshold = 10 * 24 * time.Hour // Maximum uptime age before triggering a clean up
eventsCleanUpThreshold = common.MaximumNumberOfEvents + 10 // Maximum number of events before triggering a clean up
resultsCleanUpThreshold = common.MaximumNumberOfResults + 10 // Maximum number of results before triggering a clean up
uptimeRetention = 7 * 24 * time.Hour
)
@@ -35,8 +39,7 @@ var (
// ErrDatabaseDriverNotSpecified is the error returned when the driver parameter passed in NewStore is blank
ErrDatabaseDriverNotSpecified = errors.New("database driver cannot be empty")
errServiceNotFoundInDatabase = errors.New("service does not exist in database")
errNoRowsReturned = errors.New("expected a row to be returned, but none was")
errNoRowsReturned = errors.New("expected a row to be returned, but none was")
)
// Store that leverages a database
@@ -191,6 +194,31 @@ func (s *Store) GetServiceStatusByKey(key string, params *paging.ServiceStatusPa
return serviceStatus
}
// GetUptimeByKey returns the uptime percentage during a time range
func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) {
if from.After(to) {
return 0, common.ErrInvalidTimeRange
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
serviceID, _, _, err := s.getServiceIDGroupAndNameByKey(tx, key)
if err != nil {
_ = tx.Rollback()
return 0, err
}
uptime, _, err := s.getServiceUptime(tx, serviceID, from, to)
if err != nil {
_ = tx.Rollback()
return 0, err
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
}
return uptime, nil
}
// Insert adds the observed result for the specified service into the store
func (s *Store) Insert(service *core.Service, result *core.Result) {
tx, err := s.db.Begin()
@@ -200,7 +228,7 @@ func (s *Store) Insert(service *core.Service, result *core.Result) {
//start := time.Now()
serviceID, err := s.getServiceID(tx, service)
if err != nil {
if err == errServiceNotFoundInDatabase {
if err == common.ErrServiceNotFound {
// Service doesn't exist in the database, insert it
if serviceID, err = s.insertService(tx, service); err != nil {
_ = tx.Rollback()
@@ -473,12 +501,12 @@ func (s *Store) getServiceStatusByKey(tx *sql.Tx, key string, parameters *paging
log.Printf("[sqlite][getServiceStatusByKey] Failed to retrieve results for key=%s: %s", key, err.Error())
}
}
if parameters.IncludeUptime {
now := time.Now()
serviceStatus.Uptime.LastHour, _, err = s.getServiceUptime(tx, serviceID, now.Add(-time.Hour), now)
serviceStatus.Uptime.LastTwentyFourHours, _, err = s.getServiceUptime(tx, serviceID, now.Add(-24*time.Hour), now)
serviceStatus.Uptime.LastSevenDays, _, err = s.getServiceUptime(tx, serviceID, now.Add(-7*24*time.Hour), now)
}
//if parameters.IncludeUptime {
// now := time.Now()
// serviceStatus.Uptime.LastHour, _, err = s.getServiceUptime(tx, serviceID, now.Add(-time.Hour), now)
// serviceStatus.Uptime.LastTwentyFourHours, _, err = s.getServiceUptime(tx, serviceID, now.Add(-24*time.Hour), now)
// serviceStatus.Uptime.LastSevenDays, _, err = s.getServiceUptime(tx, serviceID, now.Add(-7*24*time.Hour), now)
//}
return serviceStatus, nil
}
@@ -500,7 +528,7 @@ func (s *Store) getServiceIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64,
}
_ = rows.Close()
if id == 0 {
return 0, "", "", errServiceNotFoundInDatabase
return 0, "", "", common.ErrServiceNotFound
}
return
}
@@ -639,7 +667,7 @@ func (s *Store) getServiceID(tx *sql.Tx, service *core.Service) (int64, error) {
}
_ = rows.Close()
if !found {
return 0, errServiceNotFoundInDatabase
return 0, common.ErrServiceNotFound
}
return id, nil
}
@@ -732,7 +760,7 @@ func (s *Store) deleteOldServiceEvents(tx *sql.Tx, serviceID int64) error {
)
`,
serviceID,
core.MaximumNumberOfEvents,
common.MaximumNumberOfEvents,
)
if err != nil {
return err
@@ -757,7 +785,7 @@ func (s *Store) deleteOldServiceResults(tx *sql.Tx, serviceID int64) error {
)
`,
serviceID,
core.MaximumNumberOfResults,
common.MaximumNumberOfResults,
)
if err != nil {
return err

View File

@@ -5,7 +5,8 @@ import (
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
)
var (
@@ -157,7 +158,7 @@ func TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) {
for i := 0; i < resultsCleanUpThreshold+eventsCleanUpThreshold; i++ {
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
ss := store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithResults(1, core.MaximumNumberOfResults*5).WithEvents(1, core.MaximumNumberOfEvents*5))
ss := store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithResults(1, common.MaximumNumberOfResults*5).WithEvents(1, common.MaximumNumberOfEvents*5))
if len(ss.Results) > resultsCleanUpThreshold+1 {
t.Errorf("number of results shouldn't have exceeded %d, reached %d", resultsCleanUpThreshold, len(ss.Results))
}
@@ -173,16 +174,25 @@ func TestStore_Persistence(t *testing.T) {
store, _ := NewStore("sqlite", file)
store.Insert(&testService, &testSuccessfulResult)
store.Insert(&testService, &testUnsuccessfulResult)
ssFromOldStore := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, core.MaximumNumberOfResults).WithEvents(1, core.MaximumNumberOfEvents).WithUptime())
if ssFromOldStore == nil || ssFromOldStore.Group != "group" || ssFromOldStore.Name != "name" || len(ssFromOldStore.Events) != 3 || len(ssFromOldStore.Results) != 2 || ssFromOldStore.Uptime.LastHour != 0.5 || ssFromOldStore.Uptime.LastTwentyFourHours != 0.5 || ssFromOldStore.Uptime.LastSevenDays != 0.5 {
if uptime, _ := store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 1h should've been 0.5, got %f", uptime)
}
if uptime, _ := store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour*24), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 24h should've been 0.5, got %f", uptime)
}
if uptime, _ := store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 7d should've been 0.5, got %f", uptime)
}
ssFromOldStore := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, common.MaximumNumberOfResults).WithEvents(1, common.MaximumNumberOfEvents))
if ssFromOldStore == nil || ssFromOldStore.Group != "group" || ssFromOldStore.Name != "name" || len(ssFromOldStore.Events) != 3 || len(ssFromOldStore.Results) != 2 {
store.Close()
t.Fatal("sanity check failed")
}
store.Close()
store, _ = NewStore("sqlite", file)
defer store.Close()
ssFromNewStore := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, core.MaximumNumberOfResults).WithEvents(1, core.MaximumNumberOfEvents).WithUptime())
if ssFromNewStore == nil || ssFromNewStore.Group != "group" || ssFromNewStore.Name != "name" || len(ssFromNewStore.Events) != 3 || len(ssFromNewStore.Results) != 2 || ssFromNewStore.Uptime.LastHour != 0.5 || ssFromNewStore.Uptime.LastTwentyFourHours != 0.5 || ssFromNewStore.Uptime.LastSevenDays != 0.5 {
ssFromNewStore := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, common.MaximumNumberOfResults).WithEvents(1, common.MaximumNumberOfEvents))
if ssFromNewStore == nil || ssFromNewStore.Group != "group" || ssFromNewStore.Name != "name" || len(ssFromNewStore.Events) != 3 || len(ssFromNewStore.Results) != 2 {
t.Fatal("failed sanity check")
}
if ssFromNewStore == ssFromOldStore {

View File

@@ -2,9 +2,10 @@ package store
import (
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/common/paging"
"github.com/TwinProduction/gatus/storage/store/memory"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/sqlite"
"time"
)
// Store is the interface that each stores should implement
@@ -19,6 +20,9 @@ type Store interface {
// GetServiceStatusByKey returns the service status for a given key
GetServiceStatusByKey(key string, params *paging.ServiceStatusParams) *core.ServiceStatus
// GetUptimeByKey returns the uptime percentage during a time range
GetUptimeByKey(key string, from, to time.Time) (float64, error)
// Insert adds the observed result for the specified service into the store
Insert(service *core.Service, result *core.Result)

View File

@@ -5,8 +5,8 @@ import (
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/common/paging"
"github.com/TwinProduction/gatus/storage/store/memory"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/sqlite"
)

View File

@@ -5,8 +5,9 @@ import (
"time"
"github.com/TwinProduction/gatus/core"
"github.com/TwinProduction/gatus/storage/store/common"
"github.com/TwinProduction/gatus/storage/store/common/paging"
"github.com/TwinProduction/gatus/storage/store/memory"
"github.com/TwinProduction/gatus/storage/store/paging"
"github.com/TwinProduction/gatus/storage/store/sqlite"
)
@@ -126,7 +127,7 @@ func TestStore_GetServiceStatusByKey(t *testing.T) {
scenario.Store.Insert(&testService, &firstResult)
scenario.Store.Insert(&testService, &secondResult)
serviceStatus := scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithEvents(1, core.MaximumNumberOfEvents).WithResults(1, core.MaximumNumberOfResults).WithUptime())
serviceStatus := scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
if serviceStatus == nil {
t.Fatalf("serviceStatus shouldn't have been nil")
}
@@ -142,18 +143,6 @@ func TestStore_GetServiceStatusByKey(t *testing.T) {
if serviceStatus.Results[0].Timestamp.After(serviceStatus.Results[1].Timestamp) {
t.Error("The result at index 0 should've been older than the result at index 1")
}
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, got %f", serviceStatus.Uptime.LastHour)
}
if serviceStatus.Uptime.LastTwentyFourHours != 0.5 {
t.Errorf("serviceStatus.Uptime.LastTwentyFourHours should've been 0.5, got %f", serviceStatus.Uptime.LastTwentyFourHours)
}
if serviceStatus.Uptime.LastSevenDays != 0.5 {
t.Errorf("serviceStatus.Uptime.LastSevenDays should've been 0.5, got %f", serviceStatus.Uptime.LastSevenDays)
}
scenario.Store.Clear()
})
}
@@ -165,15 +154,15 @@ func TestStore_GetServiceStatusForMissingStatusReturnsNil(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.Insert(&testService, &testSuccessfulResult)
serviceStatus := scenario.Store.GetServiceStatus("nonexistantgroup", "nonexistantname", paging.NewServiceStatusParams().WithEvents(1, core.MaximumNumberOfEvents).WithResults(1, core.MaximumNumberOfResults).WithUptime())
serviceStatus := scenario.Store.GetServiceStatus("nonexistantgroup", "nonexistantname", paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
if serviceStatus != nil {
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", testService.Group, testService.Name)
}
serviceStatus = scenario.Store.GetServiceStatus(testService.Group, "nonexistantname", paging.NewServiceStatusParams().WithEvents(1, core.MaximumNumberOfEvents).WithResults(1, core.MaximumNumberOfResults).WithUptime())
serviceStatus = scenario.Store.GetServiceStatus(testService.Group, "nonexistantname", paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
if serviceStatus != nil {
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", testService.Group, "nonexistantname")
}
serviceStatus = scenario.Store.GetServiceStatus("nonexistantgroup", testService.Name, paging.NewServiceStatusParams().WithEvents(1, core.MaximumNumberOfEvents).WithResults(1, core.MaximumNumberOfResults).WithUptime())
serviceStatus = scenario.Store.GetServiceStatus("nonexistantgroup", testService.Name, paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
if serviceStatus != nil {
t.Errorf("Returned service status for group '%s' and name '%s' not nil after inserting the service into the store", "nonexistantgroup", testService.Name)
}
@@ -273,6 +262,36 @@ func TestStore_GetServiceStatusPage1IsHasMoreRecentResultsThanPage2(t *testing.T
}
}
func TestStore_GetUptimeByKey(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetUptimeByKey")
defer cleanUp(scenarios)
firstResult := testSuccessfulResult
firstResult.Timestamp = now.Add(-time.Minute)
secondResult := testUnsuccessfulResult
secondResult.Timestamp = now
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if _, err := scenario.Store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour), time.Now()); err != common.ErrServiceNotFound {
t.Errorf("should've returned not found because there's nothing yet, got %v", err)
}
scenario.Store.Insert(&testService, &firstResult)
scenario.Store.Insert(&testService, &secondResult)
if uptime, _ := scenario.Store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 1h should've been 0.5, got %f", uptime)
}
if uptime, _ := scenario.Store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour*24), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 24h should've been 0.5, got %f", uptime)
}
if uptime, _ := scenario.Store.GetUptimeByKey(testService.Key(), time.Now().Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {
t.Errorf("the uptime over the past 7d should've been 0.5, got %f", uptime)
}
if _, err := scenario.Store.GetUptimeByKey(testService.Key(), time.Now(), time.Now().Add(-time.Hour)); err == nil {
t.Error("should've returned an error because the parameter 'from' cannot be older than 'to'")
}
})
}
}
func TestStore_Insert(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_Insert")
defer cleanUp(scenarios)
@@ -285,7 +304,7 @@ func TestStore_Insert(t *testing.T) {
scenario.Store.Insert(&testService, &testSuccessfulResult)
scenario.Store.Insert(&testService, &testUnsuccessfulResult)
ss := scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithEvents(1, core.MaximumNumberOfEvents).WithResults(1, core.MaximumNumberOfResults).WithUptime())
ss := scenario.Store.GetServiceStatusByKey(testService.Key(), paging.NewServiceStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults))
if ss == nil {
t.Fatalf("Store should've had key '%s', but didn't", testService.Key())
}

View File

@@ -4,6 +4,6 @@ package storage
type Type string
const (
TypeInMemory Type = "inmemory" // In-memory store
TypeSQLite Type = "sqlite" // SQLite store
TypeMemory Type = "memory" // In-memory store
TypeSQLite Type = "sqlite" // SQLite store
)

View File

@@ -14,33 +14,20 @@
/>
<Pagination @page="changePage"/>
</slot>
<div v-if="uptime" class="mt-12">
<div class="mt-12">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">UPTIME</h1>
<hr />
<div class="flex space-x-4 text-center text-xl xl:text-2xl mt-3">
<div class="flex-1">
{{ prettifyUptime(uptime['7d']) }}
<h2 class="text-sm text-gray-400">Last 7 days</h2>
</div>
<div class="flex-1">
{{ prettifyUptime(uptime['24h']) }}
<h2 class="text-sm text-gray-400">Last 24 hours</h2>
</div>
<div class="flex-1">
{{ prettifyUptime(uptime['1h']) }}
<h2 class="text-sm text-gray-400">Last hour</h2>
</div>
</div>
<hr class="mt-1"/>
<h3 class="text-xl font-mono text-gray-400 mt-1 text-right">BADGES</h3>
<div v-if="serviceStatus && serviceStatus.key" class="flex space-x-4 text-center text-2xl mt-6 relative bottom-12">
<div v-if="serviceStatus && serviceStatus.key" 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">Last 7 days</h2>
<img :src="generateBadgeImageURL('7d')" alt="7d uptime badge" class="mx-auto" />
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last 24 hours</h2>
<img :src="generateBadgeImageURL('24h')" alt="24h uptime badge" class="mx-auto" />
</div>
<div class="flex-1">
<h2 class="text-sm text-gray-400 mb-1">Last hour</h2>
<img :src="generateBadgeImageURL('1h')" alt="1h uptime badge" class="mx-auto" />
</div>
</div>
@@ -159,7 +146,6 @@ export default {
return {
serviceStatus: {},
events: [],
uptime: {"7d": 0, "24h": 0, "1h": 0},
// Since this page isn't at the root, we need to modify the server URL a bit
serverUrl: SERVER_URL === '.' ? '..' : SERVER_URL,
currentPage: 1,

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