From 6942f0f8e038fe57d952b3b4d2a719e8a01ed1d0 Mon Sep 17 00:00:00 2001 From: TwinProduction Date: Thu, 19 Aug 2021 23:07:21 -0400 Subject: [PATCH] Add response time chart --- controller/controller.go | 6 +- storage/store/memory/memory.go | 24 +++ storage/store/memory/memory_test.go | 14 +- storage/store/sqlite/sqlite.go | 53 +++++ storage/store/sqlite/sqlite_test.go | 5 + storage/store/store.go | 6 +- storage/store/store_test.go | 39 ++++ web/app/package-lock.json | 222 ++++++++++++++++++-- web/app/package.json | 4 +- web/app/src/components/Chart.vue | 58 ++++++ web/app/src/views/Details.vue | 71 +++++-- web/static/css/app.css | 4 +- web/static/js/app-legacy.js | 2 +- web/static/js/app.js | 2 +- web/static/js/chunk-vendors-legacy.js | 285 +++++++++++++++++++++++++- web/static/js/chunk-vendors.js | 285 +++++++++++++++++++++++++- 16 files changed, 1033 insertions(+), 47 deletions(-) create mode 100644 web/app/src/components/Chart.vue diff --git a/controller/controller.go b/controller/controller.go index 2eae89b0..4b0199ca 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -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 { diff --git a/storage/store/memory/memory.go b/storage/store/memory/memory.go index a073d78e..e367c315 100644 --- a/storage/store/memory/memory.go +++ b/storage/store/memory/memory.go @@ -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() diff --git a/storage/store/memory/memory_test.go b/storage/store/memory/memory_test.go index acde0a3c..b887cd22 100644 --- a/storage/store/memory/memory_test.go +++ b/storage/store/memory/memory_test.go @@ -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() }) } } diff --git a/storage/store/sqlite/sqlite.go b/storage/store/sqlite/sqlite.go index 02fcba11..173c1dc7 100644 --- a/storage/store/sqlite/sqlite.go +++ b/storage/store/sqlite/sqlite.go @@ -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 { diff --git a/storage/store/sqlite/sqlite_test.go b/storage/store/sqlite/sqlite_test.go index fa2bc1c6..6380f410 100644 --- a/storage/store/sqlite/sqlite_test.go +++ b/storage/store/sqlite/sqlite_test.go @@ -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()) diff --git a/storage/store/store.go b/storage/store/store.go index 9e371364..fd02a893 100644 --- a/storage/store/store.go +++ b/storage/store/store.go @@ -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) diff --git a/storage/store/store_test.go b/storage/store/store_test.go index 28cd4406..33f0adcb 100644 --- a/storage/store/store_test.go +++ b/storage/store/store_test.go @@ -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) diff --git a/web/app/package-lock.json b/web/app/package-lock.json index 00796226..aaed36d3 100644 --- a/web/app/package-lock.json +++ b/web/app/package-lock.json @@ -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", diff --git a/web/app/package.json b/web/app/package.json index aeff1a4b..7b1f9fa4 100644 --- a/web/app/package.json +++ b/web/app/package.json @@ -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", diff --git a/web/app/src/components/Chart.vue b/web/app/src/components/Chart.vue new file mode 100644 index 00000000..5bae19d8 --- /dev/null +++ b/web/app/src/components/Chart.vue @@ -0,0 +1,58 @@ + + + + diff --git a/web/app/src/views/Details.vue b/web/app/src/views/Details.vue index 21cc9cc0..225691c5 100644 --- a/web/app/src/views/Details.vue +++ b/web/app/src/views/Details.vue @@ -1,47 +1,72 @@