From 88eb68b1a9d0ae986f2361ecfd797a1b68224c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Fri, 12 Feb 2021 18:37:30 +0100 Subject: [PATCH] feat: add prometheus metrics without external standalone exporter --- README.md | 36 +++++- config.default.yml | 3 +- config/config.go | 4 +- main.go | 2 + middlewares/authenticate.go | 12 +- mocks/heartbeat_service.go | 5 + models/metrics/counter_metric.go | 22 ++++ models/metrics/label.go | 28 +++++ models/metrics/metric.go | 43 +++++++ repositories/heartbeart.go | 10 ++ repositories/repositories.go | 1 + routes/api/metrics.go | 208 +++++++++++++++++++++++++++++++ routes/routes.go | 4 + routes/settings.go | 2 +- routes/summary.go | 2 +- services/heartbeat.go | 4 + services/misc.go | 2 +- services/services.go | 1 + utils/summary.go | 7 ++ version.txt | 2 +- views/index.tpl.html | 4 +- 21 files changed, 385 insertions(+), 17 deletions(-) create mode 100644 models/metrics/counter_metric.go create mode 100644 models/metrics/label.go create mode 100644 models/metrics/metric.go create mode 100644 routes/api/metrics.go diff --git a/README.md b/README.md index bc05434..dc2f00c 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,9 @@ You can specify configuration options either via a config file (default: `config | `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) | | `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing | | `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP | -| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE ` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies | +| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies | +| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration | +| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/metrics` | | `db.host` | `WAKAPI_DB_HOST` | - | Database host | | `db.port` | `WAKAPI_DB_PORT` | - | Database port | | `db.user` | `WAKAPI_DB_USER` | - | Database user | @@ -196,13 +198,37 @@ $ swag init -o static/docs ## 🤝 Integrations ### Prometheus Export -If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**. +You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how. -[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff)](https://github.com/MacroPower/wakatime_exporter) +```bash +# 1. Start Wakapi with the feature enabled +$ export WAKAPI_EXPOSE_METRICS=true +$ ./wakapi -It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible. +# 2. Get your API key and hash it +$ echo "" | base64 -Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly. +# 3. Add a Prometheus scrape config to your prometheus.yml (see below) +``` + +#### Scrape config example +```yml +# prometheus.yml +# (assuming your Wakapi instance listens at localhost, port 3000) + +scrape_configs: + - job_name: 'wakapi' + scrape_interval: 1m + metrics_path: '/api/metrics' + bearer_token: '' + static_configs: + - targets: ['localhost:3000'] +``` + +#### Grafana +There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter). + +![](https://grafana.com/api/dashboards/12790/images/8741/image) ### WakaTime Integration Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page. diff --git a/config.default.yml b/config.default.yml index 8b437aa..cfa0beb 100644 --- a/config.default.yml +++ b/config.default.yml @@ -29,4 +29,5 @@ security: password_salt: # CHANGE ! insecure_cookies: false cookie_max_age: 172800 - allow_signup: true \ No newline at end of file + allow_signup: true + expose_metrics: false \ No newline at end of file diff --git a/config/config.go b/config/config.go index 25999d6..e7415bc 100644 --- a/config/config.go +++ b/config/config.go @@ -47,7 +47,6 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location") type appConfig struct { AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"` - CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_TIME"` ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"` ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"` CustomLanguages map[string]string `yaml:"custom_languages"` @@ -55,7 +54,8 @@ type appConfig struct { } type securityConfig struct { - AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"` + AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"` + ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"` // this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography)) PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"` InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"` diff --git a/main.go b/main.go index 5e2fc08..bc51cc1 100644 --- a/main.go +++ b/main.go @@ -149,6 +149,7 @@ func main() { healthApiHandler := api.NewHealthApiHandler(db) heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService) summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService) + metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService) // Compat Handlers wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService) @@ -189,6 +190,7 @@ func main() { summaryApiHandler.RegisterRoutes(apiRouter) healthApiHandler.RegisterRoutes(apiRouter) heartbeatApiHandler.RegisterRoutes(apiRouter) + metricsHandler.RegisterRoutes(apiRouter) wakatimeV1AllHandler.RegisterRoutes(apiRouter) wakatimeV1SummariesHandler.RegisterRoutes(apiRouter) wakatimeV1StatsHandler.RegisterRoutes(apiRouter) diff --git a/middlewares/authenticate.go b/middlewares/authenticate.go index 2e77d29..8a56475 100644 --- a/middlewares/authenticate.go +++ b/middlewares/authenticate.go @@ -2,7 +2,6 @@ package middlewares import ( "context" - "fmt" conf "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" "github.com/muety/wakapi/services" @@ -15,6 +14,7 @@ type AuthenticateMiddleware struct { config *conf.Config userSrvc services.IUserService optionalForPaths []string + redirectTarget string // optional } func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware { @@ -30,6 +30,11 @@ func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) *AuthenticateMi return m } +func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMiddleware { + m.redirectTarget = path + return m +} + func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.ServeHTTP(w, r, h.ServeHTTP) @@ -50,11 +55,12 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques return } - if strings.HasPrefix(r.URL.Path, "/api") { + if m.redirectTarget == "" { w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("401 unauthorized")) } else { http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/")) - http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound) + http.Redirect(w, r, m.redirectTarget, http.StatusFound) } return } diff --git a/mocks/heartbeat_service.go b/mocks/heartbeat_service.go index e03afc4..68c74bf 100644 --- a/mocks/heartbeat_service.go +++ b/mocks/heartbeat_service.go @@ -20,6 +20,11 @@ func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error return args.Error(0) } +func (m *HeartbeatServiceMock) Count() (int64, error) { + args := m.Called() + return int64(args.Int(0)), args.Error(1) +} + func (m *HeartbeatServiceMock) CountByUser(user *models.User) (int64, error) { args := m.Called(user) return args.Get(0).(int64), args.Error(0) diff --git a/models/metrics/counter_metric.go b/models/metrics/counter_metric.go new file mode 100644 index 0000000..7c9f217 --- /dev/null +++ b/models/metrics/counter_metric.go @@ -0,0 +1,22 @@ +package metrics + +import "fmt" + +type CounterMetric struct { + Name string + Value int + Desc string + Labels Labels +} + +func (c CounterMetric) Key() string { + return c.Name +} + +func (c CounterMetric) Print() string { + return fmt.Sprintf("%s%s %d", c.Name, c.Labels.Print(), c.Value) +} + +func (c CounterMetric) Header() string { + return fmt.Sprintf("# HELP %s %s\n# TYPE %s counter", c.Name, c.Desc, c.Name) +} diff --git a/models/metrics/label.go b/models/metrics/label.go new file mode 100644 index 0000000..1a34e91 --- /dev/null +++ b/models/metrics/label.go @@ -0,0 +1,28 @@ +package metrics + +import ( + "fmt" + "strings" +) + +type Labels []Label + +type Label struct { + Key string + Value string +} + +func (l Labels) Print() string { + printedLabels := make([]string, len(l)) + for i, e := range l { + printedLabels[i] = e.Print() + } + if len(l) == 0 { + return "" + } + return fmt.Sprintf("{%s}", strings.Join(printedLabels, ",")) +} + +func (l Label) Print() string { + return fmt.Sprintf("%s=\"%s\"", l.Key, l.Value) +} diff --git a/models/metrics/metric.go b/models/metrics/metric.go new file mode 100644 index 0000000..d348cb1 --- /dev/null +++ b/models/metrics/metric.go @@ -0,0 +1,43 @@ +package metrics + +import ( + "fmt" + "strings" +) + +// Hand-crafted Prometheus metrics +// Since we're only using very simple counters in this application, +// we don't actually need the official client SDK as a dependency + +type Metrics []Metric + +func (m Metrics) Print() (output string) { + printedMetrics := make(map[string]bool) + for _, m := range m { + if _, ok := printedMetrics[m.Key()]; !ok { + output += fmt.Sprintf("%s\n", m.Header()) + printedMetrics[m.Key()] = true + } + output += fmt.Sprintf("%s\n", m.Print()) + } + + return output +} + +func (m Metrics) Len() int { + return len(m) +} + +func (m Metrics) Less(i, j int) bool { + return strings.Compare(m[i].Key(), m[j].Key()) < 0 +} + +func (m Metrics) Swap(i, j int) { + m[i], m[j] = m[j], m[i] +} + +type Metric interface { + Key() string + Header() string + Print() string +} diff --git a/repositories/heartbeart.go b/repositories/heartbeart.go index 87215ef..d514fd0 100644 --- a/repositories/heartbeart.go +++ b/repositories/heartbeart.go @@ -26,6 +26,16 @@ func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error return nil } +func (r *HeartbeatRepository) Count() (int64, error) { + var count int64 + if err := r.db. + Model(&models.Heartbeat{}). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) { var count int64 if err := r.db. diff --git a/repositories/repositories.go b/repositories/repositories.go index bc421b7..c6d40d2 100644 --- a/repositories/repositories.go +++ b/repositories/repositories.go @@ -17,6 +17,7 @@ type IAliasRepository interface { type IHeartbeatRepository interface { InsertBatch([]*models.Heartbeat) error + Count() (int64, error) CountByUser(*models.User) (int64, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetFirstByUsers() ([]*models.TimeByUser, error) diff --git a/routes/api/metrics.go b/routes/api/metrics.go new file mode 100644 index 0000000..7a2fbd7 --- /dev/null +++ b/routes/api/metrics.go @@ -0,0 +1,208 @@ +package api + +import ( + "github.com/emvi/logbuch" + "github.com/gorilla/mux" + conf "github.com/muety/wakapi/config" + "github.com/muety/wakapi/middlewares" + "github.com/muety/wakapi/models" + v1 "github.com/muety/wakapi/models/compat/wakatime/v1" + mm "github.com/muety/wakapi/models/metrics" + "github.com/muety/wakapi/services" + "github.com/muety/wakapi/utils" + "net/http" + "sort" + "strconv" + "time" +) + +const ( + MetricsPrefix = "wakatime" + + DescAllTime = "Total seconds (all time)." + DescTotal = "Total seconds." + DescEditors = "Total seconds for each editor." + DescProjects = "Total seconds for each project." + DescLanguages = "Total seconds for each language." + DescOperatingSystems = "Total seconds for each operating system." + DescMachines = "Total seconds for each machine." + + DescAdminTotalTime = "Total seconds (all users, all time)" + DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)" + DescAdminTotalUser = "Total number of registered users" +) + +type MetricsHandler struct { + config *conf.Config + userSrvc services.IUserService + summarySrvc services.ISummaryService + heartbeatSrvc services.IHeartbeatService + keyValueSrvc services.IKeyValueService +} + +func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService) *MetricsHandler { + return &MetricsHandler{ + userSrvc: userService, + summarySrvc: summaryService, + heartbeatSrvc: heartbeatService, + keyValueSrvc: keyValueService, + config: conf.Get(), + } +} + +func (h *MetricsHandler) RegisterRoutes(router *mux.Router) { + if !h.config.Security.ExposeMetrics { + return + } + + logbuch.Info("exposing prometheus metrics under /api/metrics") + + r := router.PathPrefix("/metrics").Subrouter() + r.Use( + middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, + ) + r.Methods(http.MethodGet).HandlerFunc(h.Get) +} + +func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) { + var metrics mm.Metrics + + user := r.Context().Value(models.UserKey).(*models.User) + if user == nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + from, to := utils.MustResolveIntervalRaw("today") + + summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + // User Metrics + + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_cumulative_seconds_total", + Desc: DescAllTime, + Value: int(v1.NewAllTimeFrom(summaryAllTime, &models.Filters{}).Data.TotalSeconds), + Labels: []mm.Label{}, + }) + + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_seconds_total", + Desc: DescTotal, + Value: int(summaryToday.TotalTime().Seconds()), + Labels: []mm.Label{}, + }) + + for _, p := range summaryToday.Projects { + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_project_seconds_total", + Desc: DescProjects, + Value: int(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()), + Labels: []mm.Label{{Key: "name", Value: p.Key}}, + }) + } + + for _, l := range summaryToday.Languages { + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_language_seconds_total", + Desc: DescLanguages, + Value: int(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()), + Labels: []mm.Label{{Key: "name", Value: l.Key}}, + }) + } + + for _, e := range summaryToday.Editors { + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_editor_seconds_total", + Desc: DescEditors, + Value: int(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()), + Labels: []mm.Label{{Key: "name", Value: e.Key}}, + }) + } + + for _, o := range summaryToday.OperatingSystems { + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_operating_system_seconds_total", + Desc: DescOperatingSystems, + Value: int(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()), + Labels: []mm.Label{{Key: "name", Value: o.Key}}, + }) + } + + for _, m := range summaryToday.Machines { + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_machine_seconds_total", + Desc: DescMachines, + Value: int(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()), + Labels: []mm.Label{{Key: "name", Value: m.Key}}, + }) + } + + // Admin metrics + + if user.IsAdmin { + var ( + totalSeconds int + totalUsers int + totalHeartbeats int + ) + + if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" { + if d, err := time.ParseDuration(t.Value); err == nil { + totalSeconds = int(d.Seconds()) + } + } + + if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && t != nil && t.Value != "" { + if d, err := strconv.Atoi(t.Value); err == nil { + totalUsers = d + } + } + + if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && t != nil && t.Value != "" { + if d, err := strconv.Atoi(t.Value); err == nil { + totalUsers = d + } + } + + if t, err := h.heartbeatSrvc.Count(); err == nil { + totalHeartbeats = int(t) + } + + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_admin_seconds_total", + Desc: DescAdminTotalTime, + Value: totalSeconds, + Labels: []mm.Label{}, + }) + + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_admin_users_total", + Desc: DescAdminTotalUser, + Value: totalUsers, + Labels: []mm.Label{}, + }) + + metrics = append(metrics, &mm.CounterMetric{ + Name: MetricsPrefix + "_admin_heartbeats_total", + Desc: DescAdminTotalHeartbeats, + Value: totalHeartbeats, + Labels: []mm.Label{}, + }) + } + + sort.Sort(metrics) + + w.Header().Set("content-type", "text/plain; charset=utf-8") + w.Write([]byte(metrics.Print())) +} diff --git a/routes/routes.go b/routes/routes.go index bf45c6f..05615a5 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -102,3 +102,7 @@ func typeName(t uint8) string { } return "unknown" } + +func defaultErrorRedirectTarget() string { + return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath) +} diff --git a/routes/settings.go b/routes/settings.go index 81f0981..940e73b 100644 --- a/routes/settings.go +++ b/routes/settings.go @@ -57,7 +57,7 @@ func NewSettingsHandler( func (h *SettingsHandler) RegisterRoutes(router *mux.Router) { r := router.PathPrefix("/settings").Subrouter() r.Use( - middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, + middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler, ) r.Methods(http.MethodGet).HandlerFunc(h.GetIndex) r.Methods(http.MethodPost).HandlerFunc(h.PostIndex) diff --git a/routes/summary.go b/routes/summary.go index 2f52df0..1c6f488 100644 --- a/routes/summary.go +++ b/routes/summary.go @@ -29,7 +29,7 @@ func NewSummaryHandler(summaryService services.ISummaryService, userService serv func (h *SummaryHandler) RegisterRoutes(router *mux.Router) { r := router.PathPrefix("/summary").Subrouter() r.Use( - middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, + middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler, ) r.Methods(http.MethodGet).HandlerFunc(h.GetIndex) } diff --git a/services/heartbeat.go b/services/heartbeat.go index 39f68e2..c2faeca 100644 --- a/services/heartbeat.go +++ b/services/heartbeat.go @@ -30,6 +30,10 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error { return srv.repository.InsertBatch(heartbeats) } +func (srv *HeartbeatService) Count() (int64, error) { + return srv.repository.Count() +} + func (srv *HeartbeatService) CountByUser(user *models.User) (int64, error) { return srv.repository.CountByUser(user) } diff --git a/services/misc.go b/services/misc.go index 820f7bd..e06dd33 100644 --- a/services/misc.go +++ b/services/misc.go @@ -46,7 +46,7 @@ func (srv *MiscService) ScheduleCountTotalTime() { } s := gocron.NewScheduler(time.Local) - s.Every(1).Day().At(srv.config.App.CountingTime).Do(srv.runCountTotalTime) + s.Every(1).Hour().Do(srv.runCountTotalTime) s.StartBlocking() } diff --git a/services/services.go b/services/services.go index 45670e8..7a60249 100644 --- a/services/services.go +++ b/services/services.go @@ -29,6 +29,7 @@ type IHeartbeatService interface { Insert(*models.Heartbeat) error InsertBatch([]*models.Heartbeat) error CountByUser(*models.User) (int64, error) + Count() (int64, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetFirstByUsers() ([]*models.TimeByUser, error) GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error) diff --git a/utils/summary.go b/utils/summary.go index 3b99c8a..4ab8716 100644 --- a/utils/summary.go +++ b/utils/summary.go @@ -16,6 +16,11 @@ func ParseInterval(interval string) (*models.IntervalKey, error) { return nil, errors.New("not a valid interval") } +func MustResolveIntervalRaw(interval string) (from, to time.Time) { + _, from, to = ResolveIntervalRaw(interval) + return from, to +} + func ResolveIntervalRaw(interval string) (err error, from, to time.Time) { parsed, err := ParseInterval(interval) if err != nil { @@ -74,6 +79,8 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) { if interval := params.Get("interval"); interval != "" { err, from, to = ResolveIntervalRaw(interval) + } else if start := params.Get("start"); start != "" { + err, from, to = ResolveIntervalRaw(start) } else { from, err = ParseDate(params.Get("from")) if err != nil { diff --git a/version.txt b/version.txt index f4ce203..d437046 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.23.7 \ No newline at end of file +1.24.0 \ No newline at end of file diff --git a/views/index.tpl.html b/views/index.tpl.html index b2c3253..1b45d23 100644 --- a/views/index.tpl.html +++ b/views/index.tpl.html @@ -27,11 +27,11 @@

💡 The system has tracked a total of {{ range $d := .TotalHours | printf "%d" | toRunes }} - {{ $d }} + {{ $d }} {{ end }} hours of coding from {{ range $d := .TotalUsers | printf "%d" | toRunes }} - {{ $d }} + {{ $d }} {{ end }} users.