Add response time chart

This commit is contained in:
TwinProduction 2021-08-19 23:07:21 -04:00 committed by Chris
parent 733760dc06
commit 6942f0f8e0
16 changed files with 1033 additions and 47 deletions

View File

@ -145,9 +145,10 @@ func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
_, _ = 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())
uptime7Days, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-24*7*time.Hour), time.Now())
uptime24Hours, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-24*time.Hour), time.Now())
uptime1Hour, _ := storage.Get().GetUptimeByKey(vars["key"], time.Now().Add(-time.Hour), time.Now())
hourlyAverageResponseTime, _ := storage.Get().GetHourlyAverageResponseTimeByKey(vars["key"], time.Now().Add(-24*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
@ -160,6 +161,7 @@ func serviceStatusHandler(writer http.ResponseWriter, r *http.Request) {
"24h": uptime24Hours,
"1h": uptime1Hour,
},
"hourlyAverageResponseTime": hourlyAverageResponseTime,
}
output, err := json.Marshal(data)
if err != nil {

View File

@ -99,6 +99,30 @@ func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error)
return float64(successfulExecutions) / float64(totalExecutions), nil
}
// GetHourlyAverageResponseTimeByKey returns a map of hourly (key) average response time in milliseconds (value) during a time range
func (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error) {
if from.After(to) {
return nil, common.ErrInvalidTimeRange
}
serviceStatus := s.cache.GetValue(key)
if serviceStatus == nil || serviceStatus.(*core.ServiceStatus).Uptime == nil {
return nil, common.ErrServiceNotFound
}
hourlyAverageResponseTimes := make(map[int64]int)
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
}
hourlyAverageResponseTimes[hourlyUnixTimestamp] = int(float64(hourlyStats.TotalExecutionsResponseTime) / float64(hourlyStats.TotalExecutions))
current = current.Add(time.Hour)
}
return hourlyAverageResponseTimes, 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

@ -13,7 +13,7 @@ var (
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
timestamp = time.Now()
now = time.Now()
testService = core.Service{
Name: "name",
@ -35,7 +35,7 @@ var (
Errors: nil,
Connected: true,
Success: true,
Timestamp: timestamp,
Timestamp: now,
Duration: 150 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
@ -60,7 +60,7 @@ var (
Errors: []string{"error-1", "error-2"},
Connected: true,
Success: false,
Timestamp: timestamp,
Timestamp: now,
Duration: 750 * time.Millisecond,
CertificateExpiration: 10 * time.Hour,
ConditionResults: []*core.ConditionResult{
@ -84,6 +84,7 @@ var (
// This test is simply an extra sanity check
func TestStore_SanityCheck(t *testing.T) {
store, _ := NewStore("")
defer store.Close()
store.Insert(&testService, &testSuccessfulResult)
if numberOfServiceStatuses := len(store.GetAllServiceStatuses(paging.NewServiceStatusParams())); numberOfServiceStatuses != 1 {
t.Fatalf("expected 1 ServiceStatus, got %d", numberOfServiceStatuses)
@ -93,6 +94,11 @@ func TestStore_SanityCheck(t *testing.T) {
if numberOfServiceStatuses := len(store.GetAllServiceStatuses(paging.NewServiceStatusParams())); numberOfServiceStatuses != 1 {
t.Fatalf("expected 1 ServiceStatus, got %d", numberOfServiceStatuses)
}
if hourlyAverageResponseTime, err := store.GetHourlyAverageResponseTimeByKey(testService.Key(), time.Now().Add(-24*time.Hour), time.Now()); err != nil {
t.Errorf("expected no error, got %v", err)
} else if len(hourlyAverageResponseTime) != 1 {
t.Errorf("expected 1 hour to have had a result in the past 24 hours, got %d", len(hourlyAverageResponseTime))
}
ss := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, 20).WithEvents(1, 20))
if ss == nil {
t.Fatalf("Store should've had key '%s', but didn't", testService.Key())
@ -123,6 +129,8 @@ func TestStore_Save(t *testing.T) {
if err != nil {
t.Fatal("expected no error, got", err.Error())
}
store.Clear()
store.Close()
})
}
}

View File

@ -219,6 +219,31 @@ func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error)
return uptime, nil
}
// GetHourlyAverageResponseTimeByKey returns a map of hourly (key) average response time in milliseconds (value) during a time range
func (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error) {
if from.After(to) {
return nil, common.ErrInvalidTimeRange
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
serviceID, _, _, err := s.getServiceIDGroupAndNameByKey(tx, key)
if err != nil {
_ = tx.Rollback()
return nil, err
}
hourlyAverageResponseTimes, err := s.getServiceHourlyAverageResponseTimes(tx, serviceID, from, to)
if err != nil {
_ = tx.Rollback()
return nil, err
}
if err = tx.Commit(); err != nil {
_ = tx.Rollback()
}
return hourlyAverageResponseTimes, 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()
@ -653,6 +678,34 @@ func (s *Store) getServiceUptime(tx *sql.Tx, serviceID int64, from, to time.Time
return
}
func (s *Store) getServiceHourlyAverageResponseTimes(tx *sql.Tx, serviceID int64, from, to time.Time) (map[int64]int, error) {
rows, err := tx.Query(
`
SELECT hour_unix_timestamp, total_executions, total_response_time
FROM service_uptime
WHERE service_id = $1
AND total_executions > 0
AND hour_unix_timestamp >= $2
AND hour_unix_timestamp <= $3
`,
serviceID,
from.Unix(),
to.Unix(),
)
if err != nil {
return nil, err
}
var totalExecutions, totalResponseTime int
var unixTimestampFlooredAtHour int64
hourlyAverageResponseTimes := make(map[int64]int)
for rows.Next() {
_ = rows.Scan(&unixTimestampFlooredAtHour, &totalExecutions, &totalResponseTime)
hourlyAverageResponseTimes[unixTimestampFlooredAtHour] = int(float64(totalResponseTime) / float64(totalExecutions))
}
_ = rows.Close()
return hourlyAverageResponseTimes, nil
}
func (s *Store) getServiceID(tx *sql.Tx, service *core.Service) (int64, error) {
rows, err := tx.Query("SELECT service_id FROM service WHERE service_key = $1", service.Key())
if err != nil {

View File

@ -274,6 +274,11 @@ func TestStore_SanityCheck(t *testing.T) {
if numberOfServiceStatuses := len(store.GetAllServiceStatuses(paging.NewServiceStatusParams())); numberOfServiceStatuses != 1 {
t.Fatalf("expected 1 ServiceStatus, got %d", numberOfServiceStatuses)
}
if hourlyAverageResponseTime, err := store.GetHourlyAverageResponseTimeByKey(testService.Key(), time.Now().Add(-24*time.Hour), time.Now()); err != nil {
t.Errorf("expected no error, got %v", err)
} else if len(hourlyAverageResponseTime) != 1 {
t.Errorf("expected 1 hour to have had a result in the past 24 hours, got %d", len(hourlyAverageResponseTime))
}
ss := store.GetServiceStatus(testService.Group, testService.Name, paging.NewServiceStatusParams().WithResults(1, 20).WithEvents(1, 20))
if ss == nil {
t.Fatalf("Store should've had key '%s', but didn't", testService.Key())

View File

@ -1,11 +1,12 @@
package store
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/sqlite"
"time"
)
// Store is the interface that each stores should implement
@ -23,6 +24,9 @@ type Store interface {
// GetUptimeByKey returns the uptime percentage during a time range
GetUptimeByKey(key string, from, to time.Time) (float64, error)
// GetHourlyAverageResponseTimeByKey returns a map of hourly (key) average response time in milliseconds (value) during a time range
GetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error)
// Insert adds the observed result for the specified service into the store
Insert(service *core.Service, result *core.Result)

View File

@ -292,6 +292,45 @@ func TestStore_GetUptimeByKey(t *testing.T) {
}
}
func TestStore_GetHourlyAverageResponseTimeByKey(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_GetHourlyAverageResponseTimeByKey")
defer cleanUp(scenarios)
firstResult := testSuccessfulResult
firstResult.Timestamp = now.Add(-(2 * time.Hour))
firstResult.Duration = 300 * time.Millisecond
secondResult := testSuccessfulResult
secondResult.Duration = 150 * time.Millisecond
secondResult.Timestamp = now.Add(-(1*time.Hour + 30*time.Minute))
thirdResult := testUnsuccessfulResult
thirdResult.Duration = 200 * time.Millisecond
thirdResult.Timestamp = now.Add(-(1 * time.Hour))
fourthResult := testSuccessfulResult
fourthResult.Duration = 500 * time.Millisecond
fourthResult.Timestamp = now
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Store.Insert(&testService, &firstResult)
scenario.Store.Insert(&testService, &secondResult)
scenario.Store.Insert(&testService, &thirdResult)
scenario.Store.Insert(&testService, &fourthResult)
hourlyAverageResponseTime, err := scenario.Store.GetHourlyAverageResponseTimeByKey(testService.Key(), now.Add(-24*time.Hour), now)
if err != nil {
t.Error("shouldn't have returned an error, got", err)
}
if key := now.Truncate(time.Hour).Unix(); hourlyAverageResponseTime[key] != 500 {
t.Errorf("expected average response time to be 500ms at %d, got %v", key, hourlyAverageResponseTime[key])
}
if key := now.Truncate(time.Hour).Add(-time.Hour).Unix(); hourlyAverageResponseTime[key] != 175 {
t.Errorf("expected average response time to be 175ms at %d, got %v", key, hourlyAverageResponseTime[key])
}
if key := now.Truncate(time.Hour).Add(-2 * time.Hour).Unix(); hourlyAverageResponseTime[key] != 300 {
t.Errorf("expected average response time to be 300ms at %d, got %v", key, hourlyAverageResponseTime[key])
}
scenario.Store.Clear()
})
}
}
func TestStore_Insert(t *testing.T) {
scenarios := initStoresAndBaseScenarios(t, "TestStore_Insert")
defer cleanUp(scenarios)

View File

@ -8,9 +8,12 @@
"name": "gatus",
"version": "2.0.0",
"dependencies": {
"chart.js": "^3.5.0",
"core-js": "^3.16.1",
"vue": "^3.2.2",
"vue-router": "^4.0.11"
"vue-chart-3": "^0.5.7",
"vue-router": "^4.0.11",
"vue3-chart-v2": "^0.8.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^5.0.0-beta.0",
@ -1803,6 +1806,14 @@
"@types/node": "*"
}
},
"node_modules/@types/chart.js": {
"version": "2.9.34",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.34.tgz",
"integrity": "sha512-CtZVk+kh1IN67dv+fB0CWmCLCRrDJgqOj15qPic2B1VCMovNO6B7Vhf/TgPpNscjhAL1j+qUntDMWb9A4ZmPTg==",
"dependencies": {
"moment": "^2.10.2"
}
},
"node_modules/@types/connect": {
"version": "3.4.34",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
@ -3907,6 +3918,28 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"node_modules/chart.js": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.0.tgz",
"integrity": "sha512-J1a4EAb1Gi/KbhwDRmoovHTRuqT8qdF0kZ4XgwxpGethJHUdDrkqyPYwke0a+BuvSeUxPf8Cos6AX2AB8H8GLA=="
},
"node_modules/chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"dependencies": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
},
"node_modules/chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"dependencies": {
"color-name": "^1.0.0"
}
},
"node_modules/chokidar": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
@ -4222,7 +4255,6 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"dependencies": {
"color-name": "1.1.3"
}
@ -4230,8 +4262,7 @@
"node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"node_modules/color-string": {
"version": "1.6.0",
@ -8158,8 +8189,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
@ -8694,6 +8724,14 @@
"integrity": "sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q==",
"dev": true
},
"node_modules/moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==",
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -8747,7 +8785,6 @@
"version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
"dev": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@ -13057,6 +13094,51 @@
"@vue/shared": "3.2.2"
}
},
"node_modules/vue-chart-3": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/vue-chart-3/-/vue-chart-3-0.5.7.tgz",
"integrity": "sha512-BccfPv2rodY6IOppYHvMluVmIJE1CHfp5uW2DXrHrm1kIzaafLwpQ5SwdrxuCevn/QhKoi7azzcxwRcoWbX9hg==",
"dependencies": {
"@vue/runtime-core": "latest",
"@vue/runtime-dom": "latest",
"csstype": "latest",
"lodash": "latest",
"nanoid": "latest",
"vue-demi": "^0.10.1"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.4",
"chart.js": "^3.1.0",
"vue": "^2.0.0 || >=3.0.0-rc.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-chart-3/node_modules/vue-demi": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.10.1.tgz",
"integrity": "sha512-L6Oi+BvmMv6YXvqv5rJNCFHEKSVu7llpWWJczqmAQYOdmPPw5PNYoz1KKS//Fxhi+4QP64dsPjtmvnYGo1jemA==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^2.6.0 || >=3.0.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-eslint-parser": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-7.10.0.tgz",
@ -13250,6 +13332,38 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"node_modules/vue3-chart-v2": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/vue3-chart-v2/-/vue3-chart-v2-0.8.2.tgz",
"integrity": "sha512-J+v3Q0ayYyWstPY1zOmdx6l/wkHT63Kzrp5X5PNNXrSUoJT8p608danWIJtncpWhuJB8qQ3t2/jWsg4WF8qJjg==",
"dependencies": {
"@types/chart.js": "^2.9.29",
"chart.js": "^2.9.4",
"core-js": "^3.6.5",
"prettier": "^2.2.1",
"vue": "^3.0.0"
}
},
"node_modules/vue3-chart-v2/node_modules/chart.js": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
"dependencies": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
},
"node_modules/vue3-chart-v2/node_modules/prettier": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz",
"integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==",
"bin": {
"prettier": "bin-prettier.js"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/watchpack": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.1.1.tgz",
@ -15883,6 +15997,14 @@
"@types/node": "*"
}
},
"@types/chart.js": {
"version": "2.9.34",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.34.tgz",
"integrity": "sha512-CtZVk+kh1IN67dv+fB0CWmCLCRrDJgqOj15qPic2B1VCMovNO6B7Vhf/TgPpNscjhAL1j+qUntDMWb9A4ZmPTg==",
"requires": {
"moment": "^2.10.2"
}
},
"@types/connect": {
"version": "3.4.34",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
@ -17586,6 +17708,28 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"chart.js": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.0.tgz",
"integrity": "sha512-J1a4EAb1Gi/KbhwDRmoovHTRuqT8qdF0kZ4XgwxpGethJHUdDrkqyPYwke0a+BuvSeUxPf8Cos6AX2AB8H8GLA=="
},
"chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"requires": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
},
"chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"requires": {
"color-name": "^1.0.0"
}
},
"chokidar": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
@ -17826,7 +17970,6 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"requires": {
"color-name": "1.1.3"
}
@ -17834,8 +17977,7 @@
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"color-string": {
"version": "1.6.0",
@ -20855,8 +20997,7 @@
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.camelcase": {
"version": "4.3.0",
@ -21281,6 +21422,11 @@
"integrity": "sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q==",
"dev": true
},
"moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -21330,8 +21476,7 @@
"nanoid": {
"version": "3.1.23",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
"dev": true
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw=="
},
"nanomatch": {
"version": "1.2.13",
@ -24604,6 +24749,27 @@
"@vue/shared": "3.2.2"
}
},
"vue-chart-3": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/vue-chart-3/-/vue-chart-3-0.5.7.tgz",
"integrity": "sha512-BccfPv2rodY6IOppYHvMluVmIJE1CHfp5uW2DXrHrm1kIzaafLwpQ5SwdrxuCevn/QhKoi7azzcxwRcoWbX9hg==",
"requires": {
"@vue/runtime-core": "latest",
"@vue/runtime-dom": "latest",
"csstype": "latest",
"lodash": "latest",
"nanoid": "latest",
"vue-demi": "^0.10.1"
},
"dependencies": {
"vue-demi": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.10.1.tgz",
"integrity": "sha512-L6Oi+BvmMv6YXvqv5rJNCFHEKSVu7llpWWJczqmAQYOdmPPw5PNYoz1KKS//Fxhi+4QP64dsPjtmvnYGo1jemA==",
"requires": {}
}
}
},
"vue-eslint-parser": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-7.10.0.tgz",
@ -24749,6 +24915,34 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"vue3-chart-v2": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/vue3-chart-v2/-/vue3-chart-v2-0.8.2.tgz",
"integrity": "sha512-J+v3Q0ayYyWstPY1zOmdx6l/wkHT63Kzrp5X5PNNXrSUoJT8p608danWIJtncpWhuJB8qQ3t2/jWsg4WF8qJjg==",
"requires": {
"@types/chart.js": "^2.9.29",
"chart.js": "^2.9.4",
"core-js": "^3.6.5",
"prettier": "^2.2.1",
"vue": "^3.0.0"
},
"dependencies": {
"chart.js": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
"requires": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
},
"prettier": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz",
"integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ=="
}
}
},
"watchpack": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.1.1.tgz",

View File

@ -8,9 +8,11 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"chart.js": "^3.5.0",
"core-js": "^3.16.1",
"vue": "^3.2.2",
"vue-router": "^4.0.11"
"vue-router": "^4.0.11",
"vue3-chart-v2": "^0.8.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^5.0.0-beta.0",

View File

@ -0,0 +1,58 @@
<script>
import { defineComponent } from 'vue'
import { Line } from 'vue3-chart-v2'
export default defineComponent({
name: 'Chart',
extends: Line,
props: {
data: {
type: Object,
required: true
}
},
watch: {
data: function (value) {
this.renderChart(value, this.options)
}
},
data() {
return {
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0,
},
scales: {
xAxes: [{
type: 'time',
time: {
stepSize: 60,
}
}],
yAxes: [{
ticks: {
min: 0,
beginAtZero: true
}
}]
},
legend: {
display: false
},
layout: {
padding: 20
}
}
}
}
})
</script>
<style>
canvas {
max-height: 300px !important;
}
</style>

View File

@ -1,47 +1,72 @@
<template>
<router-link to="../" class="absolute top-2 left-2 inline-block px-2 pb-0.5 text-lg text-black bg-gray-100 rounded hover:bg-gray-200 focus:outline-none border border-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">
<router-link to="../"
class="absolute top-2 left-2 inline-block px-2 pb-0.5 text-lg text-black bg-gray-100 rounded hover:bg-gray-200 focus:outline-none border border-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500 dark:hover:bg-gray-600">
&larr;
</router-link>
<div>
<slot v-if="serviceStatus">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RECENT CHECKS</h1>
<hr class="mb-4" />
<hr class="mb-4"/>
<Service
:data="serviceStatus"
:maximumNumberOfResults="20"
@showTooltip="showTooltip"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime" :showAverageResponseTime="showAverageResponseTime"
@toggleShowAverageResponseTime="toggleShowAverageResponseTime"
:showAverageResponseTime="showAverageResponseTime"
/>
<Pagination @page="changePage"/>
</slot>
<div class="mt-12">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">UPTIME</h1>
<hr />
<div v-if="serviceStatus && serviceStatus.key" class="flex space-x-4 text-center text-2xl mt-6 relative bottom-2 mb-10">
<hr/>
<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" />
<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" />
<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" />
<img :src="generateBadgeImageURL('1h')" alt="1h uptime badge" class="mx-auto"/>
</div>
</div>
</div>
<div class="mt-12">
<h1 class="text-xl xl:text-3xl font-mono text-gray-400">RESPONSE TIME</h1>
<hr/>
<Chart
:data="
{
labels: this.chartLabels,
datasets: [
{
label: 'Average response time (ms)',
borderColor: '#28a745',
fill: false,
tension: 0.05,
data: this.chartValues
}
]
}"
/>
</div>
<div>
<h1 class="text-xl xl:text-3xl font-mono text-gray-400 mt-4">EVENTS</h1>
<hr class="mb-4" />
<hr class="mb-4"/>
<div>
<slot v-for="event in events" :key="event">
<div class="p-3 my-4">
<h2 class="text-lg">
<img v-if="event.type === 'HEALTHY'" src="../assets/arrow-up-green.png" alt="Healthy" class="border border-green-600 rounded-full opacity-75 bg-green-100 mr-2 inline" width="26" />
<img v-else-if="event.type === 'UNHEALTHY'" src="../assets/arrow-down-red.png" alt="Unhealthy" class="border border-red-500 rounded-full opacity-75 bg-red-100 mr-2 inline" width="26" />
<img v-else-if="event.type === 'START'" src="../assets/arrow-right-black.png" alt="Start" class="border border-gray-500 rounded-full opacity-75 bg-gray-100 mr-2 inline" width="26" />
<img v-if="event.type === 'HEALTHY'" src="../assets/arrow-up-green.png" alt="Healthy"
class="border border-green-600 rounded-full opacity-75 bg-green-100 mr-2 inline" width="26"/>
<img v-else-if="event.type === 'UNHEALTHY'" src="../assets/arrow-down-red.png" alt="Unhealthy"
class="border border-red-500 rounded-full opacity-75 bg-red-100 mr-2 inline" width="26"/>
<img v-else-if="event.type === 'START'" src="../assets/arrow-right-black.png" alt="Start"
class="border border-gray-500 rounded-full opacity-75 bg-gray-100 mr-2 inline" width="26"/>
{{ event.fancyText }}
</h2>
<div class="flex mt-1 text-sm text-gray-400">
@ -67,10 +92,12 @@ import Service from '@/components/Service.vue';
import {SERVER_URL} from "@/main.js";
import {helper} from "@/mixins/helper.js";
import Pagination from "@/components/Pagination";
import Chart from "@/components/Chart";
export default {
name: 'Details',
components: {
Chart,
Pagination,
Service,
Settings,
@ -86,10 +113,19 @@ export default {
if (JSON.stringify(this.serviceStatus) !== JSON.stringify(data)) {
this.serviceStatus = data.serviceStatus;
this.uptime = data.uptime;
let labels = [];
let values = [];
for (let key in data.hourlyAverageResponseTime) {
labels.push(new Date(key*1000));
values.push(data.hourlyAverageResponseTime[key]);
}
this.chartLabels = labels;
this.chartValues = values;
let events = [];
for (let i = data.events.length-1; i >= 0; i--) {
for (let i = data.events.length - 1; i >= 0; i--) {
let event = data.events[i];
if (i === data.events.length-1) {
if (i === data.events.length - 1) {
if (event.type === 'UNHEALTHY') {
event.fancyText = 'Service is unhealthy';
} else if (event.type === 'HEALTHY') {
@ -98,7 +134,7 @@ export default {
event.fancyText = 'Monitoring started';
}
} else {
let nextEvent = data.events[i+1];
let nextEvent = data.events[i + 1];
if (event.type === 'HEALTHY') {
event.fancyText = 'Service became healthy';
} else if (event.type === 'UNHEALTHY') {
@ -128,7 +164,7 @@ export default {
return (uptime * 100).toFixed(2) + '%'
},
prettifyTimeDifference(start, end) {
let minutes = Math.ceil((new Date(start) - new Date(end))/1000/60);
let minutes = Math.ceil((new Date(start) - new Date(end)) / 1000 / 60);
return minutes + (minutes === 1 ? ' minute' : ' minutes');
},
changePage(page) {
@ -145,11 +181,14 @@ export default {
data() {
return {
serviceStatus: {},
uptime: {},
events: [],
// Since this page isn't at the root, we need to modify the server URL a bit
serverUrl: SERVER_URL === '.' ? '..' : SERVER_URL,
currentPage: 1,
showAverageResponseTime: true,
chartLabels: [],
chartValues: [],
}
},
created() {

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long