1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

...

26 Commits

Author SHA1 Message Date
625ca8215e fix: concurrent access to language mappings (resolve #83) 2021-01-07 10:52:49 +01:00
9e735eb47e fix: do not attempt to bind on ipv6 in docker 2021-01-07 10:04:36 +01:00
9766d8e903 feat: ability to choose number of top entities to display (resolve #81) 2021-01-05 12:41:01 +01:00
39c4777fc8 fix: crash on fail to listen 2021-01-05 11:28:51 +01:00
143c80b7b4 docs: update readme 2021-01-04 11:39:02 +01:00
72e42a9c42 feat: add ipv6 and tls support (resolve #79) 2020-12-12 22:07:00 +01:00
439a87dec9 docs: advanced setup instructions for client-side reverse proxy 2020-12-11 23:05:49 +01:00
e8067bb13e fix: crash when running aggregation job on schedule (fix #78)
chore: move from gocron to its maintained fork
2020-12-11 10:05:17 +01:00
219e969957 Merge branch 'master' of github.com:muety/wakapi into master 2020-12-02 23:16:48 +01:00
e610bb3ee3 fix: html footer rendering
chore: update chartjs
2020-12-02 23:16:12 +01:00
889edd7a33 Merge pull request #77 from notarock/master
Added missing closing parens in language mapping description
2020-12-01 07:48:04 +01:00
4161623c24 Added missing closing parens 2020-11-30 21:42:29 -05:00
67fe6eea56 chore: even more code smell 2020-11-28 20:57:13 +01:00
095fef4868 chore: minor code smell 2020-11-28 20:50:35 +01:00
a0e64ca955 chore: show badges on front page 2020-11-28 20:44:39 +01:00
903defca99 fix: commit missing files
chore: add favicon
2020-11-28 20:31:28 +01:00
16b9aa2282 feat: add front page (resolve #34) 2020-11-28 20:23:40 +01:00
4a78f66778 chore: set samesite attributes and configurable max age for cookies (resolve #75)
fix: sort entities by total time descending (resolve #74)
2020-11-21 22:30:56 +01:00
f4328c452f test: add essential unit tests for core functionality (resolve #6) 2020-11-14 12:30:45 +01:00
e806e5455e chore: attempt to exclude test and mock code from analysis 2020-11-08 13:13:48 +01:00
97e1fb27eb chore: attempt to configure coverage for sonar 2020-11-08 13:07:37 +01:00
ad8168801c test: add first few unit tests 2020-11-08 12:46:12 +01:00
35cdc7b485 refactor: define interface types for all services and repositories 2020-11-08 10:12:49 +01:00
664714de8f fix: filters 2020-11-07 18:39:36 +01:00
7befb82814 chore: remove clean up related parameters 2020-11-07 12:34:17 +01:00
2f12d8efde refactor: simplify summary generation (resolve #68) 2020-11-07 12:01:35 +01:00
77 changed files with 2629 additions and 678 deletions

View File

@ -1 +1,6 @@
.env .env
config*.yml
!config.default.yml
*.db
*.exe
wakapi

3
.gitignore vendored
View File

@ -6,5 +6,6 @@ wakapi
build build
*.exe *.exe
*.db *.db
config.yml config*.yml
!config.default.yml
config.ini config.ini

View File

@ -1,6 +1,6 @@
# Build Stage # Build Stage
FROM golang:1.13 AS build-env FROM golang:1.15 AS build-env
WORKDIR /src WORKDIR /src
ADD ./go.mod . ADD ./go.mod .
RUN go mod download RUN go mod download
@ -8,19 +8,11 @@ RUN go mod download
ADD . . ADD . .
RUN go build -o wakapi RUN go build -o wakapi
# Final Stage # Run Stage
# When running the application using `docker run`, you can pass environment variables # When running the application using `docker run`, you can pass environment variables
# to override config values using `-e` syntax. # to override config values using `-e` syntax.
# Available options are: # Available options can be found in [README.md#-configuration](README.md#-configuration)
# WAKAPI_DB_TYPE
# WAKAPI_DB_USER
# WAKAPI_DB_PASSWORD
# WAKAPI_DB_HOST
# WAKAPI_DB_PORT
# WAKAPI_DB_NAME
# WAKAPI_PASSWORD_SALT
# WAKAPI_BASE_PATH
FROM debian FROM debian
WORKDIR /app WORKDIR /app
@ -32,13 +24,14 @@ ENV WAKAPI_DB_PASSWORD ''
ENV WAKAPI_DB_HOST '' ENV WAKAPI_DB_HOST ''
ENV WAKAPI_DB_NAME=/data/wakapi.db ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT '' ENV WAKAPI_PASSWORD_SALT ''
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
ENV WAKAPI_INSECURE_COOKIES 'true'
COPY --from=build-env /src/wakapi /app/ COPY --from=build-env /src/wakapi /app/
COPY --from=build-env /src/config.default.yml /app/config.yml COPY --from=build-env /src/config.default.yml /app/config.yml
COPY --from=build-env /src/version.txt /app/ COPY --from=build-env /src/version.txt /app/
RUN sed -i 's/listen_ipv4: 127.0.0.1/listen_ipv4: 0.0.0.0/g' /app/config.yml RUN sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' /app/config.yml
RUN sed -i 's/insecure_cookies: false/insecure_cookies: true/g' /app/config.yml
ADD static /app/static ADD static /app/static
ADD data /app/data ADD data /app/data

View File

@ -1,6 +1,6 @@
# 📈 wakapi # 📈 wakapi
![](https://img.shields.io/github/license/muety/wakapi) ![](https://badges.fw-web.space/github/license/muety/wakapi)
![GitHub release (latest by date)](https://badges.fw-web.space/github/v/release/muety/wakapi) ![GitHub release (latest by date)](https://badges.fw-web.space/github/v/release/muety/wakapi)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/muety/wakapi) ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/muety/wakapi)
![Docker Cloud Build Status](https://badges.fw-web.space/docker/cloud/build/n1try/wakapi) ![Docker Cloud Build Status](https://badges.fw-web.space/docker/cloud/build/n1try/wakapi)
@ -21,9 +21,10 @@
![Wakapi screenshot](https://anchr.io/i/bxQ69.png) ![Wakapi screenshot](https://anchr.io/i/bxQ69.png)
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi! ## 📬 **User Survey**
I'd love to get some community feedback from active Wakapi users. If you like, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
## 👀 Hosted Service ## 👀 Demo
🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly. 🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly.
To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕ To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
@ -52,25 +53,33 @@ To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. Howe
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies` to `true` in `config.yml`. **Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies` to `true` in `config.yml`.
### Run with Docker ### Run with Docker
``` ```bash
docker run -d -p 3000:3000 --name wakapi n1try/wakapi docker run -d -p 3000:3000 -e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" --name wakapi n1try/wakapi
``` ```
By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options. By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
### Running tests
```bash
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
```
## 🔧 Configuration ## 🔧 Configuration
You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options. You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options.
| YAML Key | Environment Variable | Default | Description | | YAML Key | Environment Variable | Default | Description |
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------| |---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings | | `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
| `app.cleanup` | `WAKAPI_CLEANUP` | `false` | Whether or not to clean up old heartbeats (be careful!) |
| `app.custom_languages` | - | - | Map from file endings to language names | | `app.custom_languages` | - | - | Map from file endings to language names |
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on | | `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | Network address to listen on | | `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) | | `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.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.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 |
| `db.host` | `WAKAPI_DB_HOST` | - | Database host | | `db.host` | `WAKAPI_DB_HOST` | - | Database host |
| `db.port` | `WAKAPI_DB_PORT` | - | Database port | | `db.port` | `WAKAPI_DB_PORT` | - | Database port |
| `db.user` | `WAKAPI_DB_USER` | - | Database user | | `db.user` | `WAKAPI_DB_USER` | - | Database user |
@ -92,6 +101,10 @@ api_key = the_api_key_printed_to_the_console_after_starting_the_server`
You can view your API Key after logging in to the web interface. You can view your API Key after logging in to the web interface.
### Optional: Client-side proxy
See the [advanced setup instructions](docs/advanced_setup.md).
## 🔵 Customization ## 🔵 Customization
### Aliases ### Aliases
@ -136,6 +149,9 @@ We recently introduced support for [Shields.io](https://shields.io) badges (see
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml` However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
## 🙏 Support
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
## ⚠️ Important Note ## ⚠️ Important Note
**This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!** **This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!**

View File

@ -1,12 +1,14 @@
env: development env: development
server: server:
listen_ipv4: 127.0.0.1 listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6
tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https
port: 3000 port: 3000
base_path: / base_path: /
app: app:
cleanup: false # only edit, if you know what you're doing
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
custom_languages: custom_languages:
vue: Vue vue: Vue
@ -24,3 +26,4 @@ db:
security: security:
password_salt: # CHANGE ! password_salt: # CHANGE !
insecure_cookies: false insecure_cookies: false
cookie_max_age: 172800

View File

@ -14,6 +14,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"io/ioutil" "io/ioutil"
"log" "log"
"net/http"
"os" "os"
"strings" "strings"
) )
@ -28,13 +29,10 @@ const (
SQLDialectSqlite = "sqlite3" SQLDialectSqlite = "sqlite3"
) )
var ( var cfg *Config
cfg *Config var cFlag = flag.String("config", defaultConfigPath, "config file location")
cFlag *string
)
type appConfig struct { type appConfig struct {
CleanUp bool `default:"false" env:"WAKAPI_CLEANUP"`
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"` AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
CustomLanguages map[string]string `yaml:"custom_languages"` CustomLanguages map[string]string `yaml:"custom_languages"`
LanguageColors map[string]string `yaml:"-"` LanguageColors map[string]string `yaml:"-"`
@ -44,6 +42,7 @@ type securityConfig struct {
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography)) // this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"` PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"` InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
SecureCookie *securecookie.SecureCookie `yaml:"-"` SecureCookie *securecookie.SecureCookie `yaml:"-"`
} }
@ -58,9 +57,12 @@ type dbConfig struct {
} }
type serverConfig struct { type serverConfig struct {
Port int `default:"3000" env:"WAKAPI_PORT"` Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"` ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"` ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
} }
type Config struct { type Config struct {
@ -72,15 +74,34 @@ type Config struct {
Server serverConfig Server serverConfig
} }
func init() { func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
cFlag = flag.String("c", defaultConfigPath, "config file location") return c.createCookie(name, value, path, c.Security.CookieMaxAgeSec)
flag.Parse() }
func (c *Config) GetClearCookie(name, path string) *http.Cookie {
return c.createCookie(name, "", path, -1)
}
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: path,
MaxAge: maxAge,
Secure: !c.Security.InsecureCookies,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
} }
func (c *Config) IsDev() bool { func (c *Config) IsDev() bool {
return IsDev(c.Env) return IsDev(c.Env)
} }
func (c *Config) UseTLS() bool {
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
}
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc { func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
switch dbDialect { switch dbDialect {
default: default:
@ -158,6 +179,14 @@ func sqliteConnectionString(config *dbConfig) string {
return config.Name return config.Name
} }
func (c *appConfig) GetCustomLanguages() map[string]string {
return cloneStringMap(c.CustomLanguages)
}
func (c *appConfig) GetLanguageColors() map[string]string {
return cloneStringMap(c.LanguageColors)
}
func IsDev(env string) bool { func IsDev(env string) bool {
return env == "dev" || env == "development" return env == "dev" || env == "development"
} }
@ -221,6 +250,8 @@ func Get() *Config {
func Load() *Config { func Load() *Config {
config := &Config{} config := &Config{}
flag.Parse()
maybeMigrateLegacyConfig() maybeMigrateLegacyConfig()
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil { if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
@ -244,6 +275,10 @@ func Load() *Config {
} }
} }
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
log.Fatalln("either of listen_ipv4 or listen_ipv6 must be set")
}
Set(config) Set(config)
return Get() return Get()
} }

66
config/config_test.go Normal file
View File

@ -0,0 +1,66 @@
package config
import (
"fmt"
"github.com/stretchr/testify/assert"
"testing"
)
func TestConfig_IsDev(t *testing.T) {
assert.True(t, IsDev("dev"))
assert.True(t, IsDev("development"))
assert.False(t, IsDev("prod"))
assert.False(t, IsDev("production"))
assert.False(t, IsDev("anything else"))
}
func Test_mysqlConnectionString(t *testing.T) {
c := &dbConfig{
Host: "test_host",
Port: 9999,
User: "test_user",
Password: "test_password",
Name: "test_name",
Dialect: "mysql",
MaxConn: 10,
}
assert.Equal(t, fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
c.User,
c.Password,
c.Host,
c.Port,
c.Name,
"Local",
), mysqlConnectionString(c))
}
func Test_postgresConnectionString(t *testing.T) {
c := &dbConfig{
Host: "test_host",
Port: 9999,
User: "test_user",
Password: "test_password",
Name: "test_name",
Dialect: "postgres",
MaxConn: 10,
}
assert.Equal(t, fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
c.Host,
c.Port,
c.User,
c.Name,
c.Password,
), postgresConnectionString(c))
}
func Test_sqliteConnectionString(t *testing.T) {
c := &dbConfig{
Name: "test_name",
Dialect: "sqlite3",
}
assert.Equal(t, c.Name, sqliteConnectionString(c))
}

View File

@ -2,6 +2,7 @@ package config
const ( const (
IndexTemplate = "index.tpl.html" IndexTemplate = "index.tpl.html"
LoginTemplate = "login.tpl.html"
ImprintTemplate = "imprint.tpl.html" ImprintTemplate = "imprint.tpl.html"
SignupTemplate = "signup.tpl.html" SignupTemplate = "signup.tpl.html"
SettingsTemplate = "settings.tpl.html" SettingsTemplate = "settings.tpl.html"

9
config/utils.go Normal file
View File

@ -0,0 +1,9 @@
package config
func cloneStringMap(m map[string]string) map[string]string {
m2 := make(map[string]string)
for k, v := range m {
m2[k] = v
}
return m2
}

519
coverage/coverage.out Normal file
View File

@ -0,0 +1,519 @@
mode: set
github.com/muety/wakapi/models/shared.go:34.52,37.16 3 0
github.com/muety/wakapi/models/shared.go:40.2,42.12 3 0
github.com/muety/wakapi/models/shared.go:37.16,39.3 1 0
github.com/muety/wakapi/models/shared.go:46.52,52.22 2 0
github.com/muety/wakapi/models/shared.go:68.2,71.12 3 0
github.com/muety/wakapi/models/shared.go:53.14,55.17 2 0
github.com/muety/wakapi/models/shared.go:58.13,60.8 2 0
github.com/muety/wakapi/models/shared.go:61.17,63.8 2 0
github.com/muety/wakapi/models/shared.go:64.10,65.64 1 0
github.com/muety/wakapi/models/shared.go:55.17,57.4 1 0
github.com/muety/wakapi/models/shared.go:74.51,77.2 2 0
github.com/muety/wakapi/models/shared.go:79.37,82.2 2 0
github.com/muety/wakapi/models/shared.go:84.35,86.2 1 0
github.com/muety/wakapi/models/shared.go:88.34,90.2 1 0
github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0
github.com/muety/wakapi/models/filters.go:29.2,29.19 1 0
github.com/muety/wakapi/models/filters.go:18.22,19.32 1 0
github.com/muety/wakapi/models/filters.go:20.17,21.27 1 0
github.com/muety/wakapi/models/filters.go:22.23,23.33 1 0
github.com/muety/wakapi/models/filters.go:24.21,25.31 1 0
github.com/muety/wakapi/models/filters.go:26.22,27.32 1 0
github.com/muety/wakapi/models/filters.go:32.49,33.21 1 0
github.com/muety/wakapi/models/filters.go:44.2,44.21 1 0
github.com/muety/wakapi/models/filters.go:33.21,35.3 1 0
github.com/muety/wakapi/models/filters.go:35.8,35.23 1 0
github.com/muety/wakapi/models/filters.go:35.23,37.3 1 0
github.com/muety/wakapi/models/filters.go:37.8,37.29 1 0
github.com/muety/wakapi/models/filters.go:37.29,39.3 1 0
github.com/muety/wakapi/models/filters.go:39.8,39.27 1 0
github.com/muety/wakapi/models/filters.go:39.27,41.3 1 0
github.com/muety/wakapi/models/filters.go:41.8,41.28 1 0
github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0
github.com/muety/wakapi/models/filters.go:47.42,50.21 2 1
github.com/muety/wakapi/models/filters.go:53.2,53.20 1 1
github.com/muety/wakapi/models/filters.go:56.2,56.22 1 1
github.com/muety/wakapi/models/filters.go:59.2,59.21 1 1
github.com/muety/wakapi/models/filters.go:62.2,62.16 1 1
github.com/muety/wakapi/models/filters.go:66.2,66.12 1 1
github.com/muety/wakapi/models/filters.go:50.21,52.3 1 1
github.com/muety/wakapi/models/filters.go:53.20,55.3 1 0
github.com/muety/wakapi/models/filters.go:56.22,58.3 1 1
github.com/muety/wakapi/models/filters.go:59.21,61.3 1 0
github.com/muety/wakapi/models/filters.go:62.16,64.3 1 0
github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0
github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0
github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0
github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0
github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0
github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0
github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0
github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0
github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0
github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
github.com/muety/wakapi/models/heartbeat.go:26.34,28.2 1 1
github.com/muety/wakapi/models/heartbeat.go:30.65,31.28 1 1
github.com/muety/wakapi/models/heartbeat.go:34.2,35.45 2 1
github.com/muety/wakapi/models/heartbeat.go:38.2,39.44 2 1
github.com/muety/wakapi/models/heartbeat.go:42.2,42.42 1 1
github.com/muety/wakapi/models/heartbeat.go:31.28,33.3 1 1
github.com/muety/wakapi/models/heartbeat.go:35.45,37.3 1 0
github.com/muety/wakapi/models/heartbeat.go:39.44,41.3 1 0
github.com/muety/wakapi/models/heartbeat.go:45.50,46.11 1 1
github.com/muety/wakapi/models/heartbeat.go:59.2,59.15 1 1
github.com/muety/wakapi/models/heartbeat.go:63.2,63.12 1 1
github.com/muety/wakapi/models/heartbeat.go:47.22,48.18 1 1
github.com/muety/wakapi/models/heartbeat.go:49.21,50.17 1 1
github.com/muety/wakapi/models/heartbeat.go:51.23,52.19 1 1
github.com/muety/wakapi/models/heartbeat.go:53.17,54.26 1 1
github.com/muety/wakapi/models/heartbeat.go:55.22,56.18 1 1
github.com/muety/wakapi/models/heartbeat.go:59.15,61.3 1 1
github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0
github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0
github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0
github.com/muety/wakapi/models/summary.go:29.27,33.2 1 0
github.com/muety/wakapi/models/summary.go:83.29,85.2 1 1
github.com/muety/wakapi/models/summary.go:87.37,94.2 6 1
github.com/muety/wakapi/models/summary.go:96.35,98.2 1 1
github.com/muety/wakapi/models/summary.go:100.57,108.2 1 1
github.com/muety/wakapi/models/summary.go:121.33,126.26 4 1
github.com/muety/wakapi/models/summary.go:133.2,133.37 1 1
github.com/muety/wakapi/models/summary.go:137.2,140.33 2 1
github.com/muety/wakapi/models/summary.go:126.26,127.30 1 1
github.com/muety/wakapi/models/summary.go:127.30,129.4 1 1
github.com/muety/wakapi/models/summary.go:133.37,135.3 1 0
github.com/muety/wakapi/models/summary.go:140.33,146.3 1 1
github.com/muety/wakapi/models/summary.go:149.45,154.30 3 1
github.com/muety/wakapi/models/summary.go:163.2,163.30 1 1
github.com/muety/wakapi/models/summary.go:154.30,155.47 1 1
github.com/muety/wakapi/models/summary.go:155.47,156.32 1 1
github.com/muety/wakapi/models/summary.go:159.4,159.9 1 1
github.com/muety/wakapi/models/summary.go:156.32,158.5 1 1
github.com/muety/wakapi/models/summary.go:166.73,168.55 2 1
github.com/muety/wakapi/models/summary.go:173.2,173.16 1 1
github.com/muety/wakapi/models/summary.go:168.55,169.31 1 1
github.com/muety/wakapi/models/summary.go:169.31,171.4 1 1
github.com/muety/wakapi/models/summary.go:176.88,178.55 2 1
github.com/muety/wakapi/models/summary.go:186.2,186.16 1 1
github.com/muety/wakapi/models/summary.go:178.55,179.31 1 1
github.com/muety/wakapi/models/summary.go:179.31,180.23 1 1
github.com/muety/wakapi/models/summary.go:183.4,183.46 1 1
github.com/muety/wakapi/models/summary.go:180.23,181.13 1 1
github.com/muety/wakapi/models/summary.go:189.79,190.33 1 1
github.com/muety/wakapi/models/summary.go:193.2,193.16 1 1
github.com/muety/wakapi/models/summary.go:190.33,192.3 1 1
github.com/muety/wakapi/models/summary.go:196.71,197.63 1 1
github.com/muety/wakapi/models/summary.go:237.2,243.10 6 1
github.com/muety/wakapi/models/summary.go:197.63,200.45 2 1
github.com/muety/wakapi/models/summary.go:209.3,209.31 1 1
github.com/muety/wakapi/models/summary.go:216.3,216.31 1 1
github.com/muety/wakapi/models/summary.go:233.3,233.16 1 1
github.com/muety/wakapi/models/summary.go:200.45,201.32 1 1
github.com/muety/wakapi/models/summary.go:206.4,206.14 1 1
github.com/muety/wakapi/models/summary.go:201.32,202.24 1 1
github.com/muety/wakapi/models/summary.go:202.24,204.6 1 1
github.com/muety/wakapi/models/summary.go:209.31,211.60 1 1
github.com/muety/wakapi/models/summary.go:211.60,213.5 1 1
github.com/muety/wakapi/models/summary.go:216.31,218.60 1 1
github.com/muety/wakapi/models/summary.go:218.60,219.55 1 1
github.com/muety/wakapi/models/summary.go:219.55,221.6 1 1
github.com/muety/wakapi/models/summary.go:221.11,229.6 1 1
github.com/muety/wakapi/models/summary.go:246.33,248.2 1 1
github.com/muety/wakapi/models/summary.go:250.43,252.2 1 1
github.com/muety/wakapi/models/summary.go:254.38,256.2 1 1
github.com/muety/wakapi/models/user.go:34.43,37.2 1 0
github.com/muety/wakapi/models/user.go:39.33,43.2 1 0
github.com/muety/wakapi/models/user.go:45.45,47.2 1 0
github.com/muety/wakapi/models/user.go:49.45,51.2 1 0
github.com/muety/wakapi/config/config.go:77.70,79.2 1 0
github.com/muety/wakapi/config/config.go:81.65,83.2 1 0
github.com/muety/wakapi/config/config.go:85.82,95.2 1 0
github.com/muety/wakapi/config/config.go:97.31,99.2 1 0
github.com/muety/wakapi/config/config.go:101.32,103.2 1 0
github.com/muety/wakapi/config/config.go:105.74,106.19 1 0
github.com/muety/wakapi/config/config.go:107.10,108.34 1 0
github.com/muety/wakapi/config/config.go:108.34,117.4 8 0
github.com/muety/wakapi/config/config.go:121.73,122.33 1 0
github.com/muety/wakapi/config/config.go:122.33,130.17 5 0
github.com/muety/wakapi/config/config.go:134.3,135.13 2 0
github.com/muety/wakapi/config/config.go:130.17,132.4 1 0
github.com/muety/wakapi/config/config.go:139.50,140.19 1 0
github.com/muety/wakapi/config/config.go:153.2,153.12 1 0
github.com/muety/wakapi/config/config.go:141.23,145.5 1 0
github.com/muety/wakapi/config/config.go:146.26,149.5 1 0
github.com/muety/wakapi/config/config.go:150.24,151.48 1 0
github.com/muety/wakapi/config/config.go:156.53,166.2 1 1
github.com/muety/wakapi/config/config.go:168.56,176.2 1 1
github.com/muety/wakapi/config/config.go:178.54,180.2 1 1
github.com/muety/wakapi/config/config.go:182.60,184.2 1 0
github.com/muety/wakapi/config/config.go:186.59,188.2 1 0
github.com/muety/wakapi/config/config.go:190.29,192.2 1 1
github.com/muety/wakapi/config/config.go:194.27,196.16 2 0
github.com/muety/wakapi/config/config.go:199.2,202.16 3 0
github.com/muety/wakapi/config/config.go:206.2,206.22 1 0
github.com/muety/wakapi/config/config.go:196.16,198.3 1 0
github.com/muety/wakapi/config/config.go:202.16,204.3 1 0
github.com/muety/wakapi/config/config.go:209.45,219.16 4 0
github.com/muety/wakapi/config/config.go:223.2,223.57 1 0
github.com/muety/wakapi/config/config.go:227.2,227.30 1 0
github.com/muety/wakapi/config/config.go:231.2,231.15 1 0
github.com/muety/wakapi/config/config.go:219.16,221.3 1 0
github.com/muety/wakapi/config/config.go:223.57,225.3 1 0
github.com/muety/wakapi/config/config.go:227.30,229.3 1 0
github.com/muety/wakapi/config/config.go:234.38,235.43 1 0
github.com/muety/wakapi/config/config.go:239.2,239.15 1 0
github.com/muety/wakapi/config/config.go:235.43,237.3 1 0
github.com/muety/wakapi/config/config.go:242.26,244.2 1 0
github.com/muety/wakapi/config/config.go:246.20,248.2 1 0
github.com/muety/wakapi/config/config.go:250.21,257.96 4 0
github.com/muety/wakapi/config/config.go:261.2,268.52 4 0
github.com/muety/wakapi/config/config.go:272.2,272.47 1 0
github.com/muety/wakapi/config/config.go:278.2,278.70 1 0
github.com/muety/wakapi/config/config.go:282.2,283.14 2 0
github.com/muety/wakapi/config/config.go:257.96,259.3 1 0
github.com/muety/wakapi/config/config.go:268.52,270.3 1 0
github.com/muety/wakapi/config/config.go:272.47,273.14 1 0
github.com/muety/wakapi/config/config.go:273.14,275.4 1 0
github.com/muety/wakapi/config/config.go:278.70,280.3 1 0
github.com/muety/wakapi/config/legacy.go:13.33,14.57 1 0
github.com/muety/wakapi/config/legacy.go:14.57,16.3 1 0
github.com/muety/wakapi/config/legacy.go:16.8,16.16 1 0
github.com/muety/wakapi/config/legacy.go:16.16,18.47 2 0
github.com/muety/wakapi/config/legacy.go:21.3,21.128 1 0
github.com/muety/wakapi/config/legacy.go:18.47,20.4 1 0
github.com/muety/wakapi/config/legacy.go:25.48,26.54 1 0
github.com/muety/wakapi/config/legacy.go:31.2,31.18 1 0
github.com/muety/wakapi/config/legacy.go:26.54,28.3 1 0
github.com/muety/wakapi/config/legacy.go:28.8,28.32 1 0
github.com/muety/wakapi/config/legacy.go:28.32,30.3 1 0
github.com/muety/wakapi/config/legacy.go:34.34,37.16 2 0
github.com/muety/wakapi/config/legacy.go:40.2,41.16 2 0
github.com/muety/wakapi/config/legacy.go:45.2,57.16 11 0
github.com/muety/wakapi/config/legacy.go:61.2,61.18 1 0
github.com/muety/wakapi/config/legacy.go:65.2,69.16 5 0
github.com/muety/wakapi/config/legacy.go:73.2,75.23 3 0
github.com/muety/wakapi/config/legacy.go:80.2,82.33 3 0
github.com/muety/wakapi/config/legacy.go:87.2,114.16 3 0
github.com/muety/wakapi/config/legacy.go:119.2,119.78 1 0
github.com/muety/wakapi/config/legacy.go:123.2,123.12 1 0
github.com/muety/wakapi/config/legacy.go:37.16,39.3 1 0
github.com/muety/wakapi/config/legacy.go:41.16,43.3 1 0
github.com/muety/wakapi/config/legacy.go:57.16,59.3 1 0
github.com/muety/wakapi/config/legacy.go:61.18,63.3 1 0
github.com/muety/wakapi/config/legacy.go:69.16,71.3 1 0
github.com/muety/wakapi/config/legacy.go:75.23,77.3 1 0
github.com/muety/wakapi/config/legacy.go:82.33,84.3 1 0
github.com/muety/wakapi/config/legacy.go:114.16,116.3 1 0
github.com/muety/wakapi/config/legacy.go:119.78,121.3 1 0
github.com/muety/wakapi/config/utils.go:3.60,5.22 2 0
github.com/muety/wakapi/config/utils.go:8.2,8.11 1 0
github.com/muety/wakapi/config/utils.go:5.22,7.3 1 0
github.com/muety/wakapi/utils/color.go:8.93,10.41 2 0
github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
github.com/muety/wakapi/utils/color.go:10.41,11.50 1 0
github.com/muety/wakapi/utils/color.go:11.50,13.4 1 0
github.com/muety/wakapi/utils/common.go:9.48,11.2 1 0
github.com/muety/wakapi/utils/common.go:13.40,15.2 1 0
github.com/muety/wakapi/utils/common.go:17.45,19.2 1 0
github.com/muety/wakapi/utils/common.go:21.56,24.45 3 1
github.com/muety/wakapi/utils/common.go:27.2,27.40 1 1
github.com/muety/wakapi/utils/common.go:24.45,26.3 1 1
github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0
github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0
github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0
github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0
github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0
github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0
github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0
github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0
github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0
github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0
github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0
github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0
github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0
github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0
github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0
github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0
github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0
github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0
github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0
github.com/muety/wakapi/utils/http.go:12.58,14.3 1 0
github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0
github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0
github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0
github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0
github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0
github.com/muety/wakapi/utils/summary.go:10.71,13.18 2 0
github.com/muety/wakapi/utils/summary.go:37.2,37.22 1 0
github.com/muety/wakapi/utils/summary.go:14.28,15.24 1 0
github.com/muety/wakapi/utils/summary.go:16.32,18.22 2 0
github.com/muety/wakapi/utils/summary.go:19.31,20.23 1 0
github.com/muety/wakapi/utils/summary.go:21.32,22.24 1 0
github.com/muety/wakapi/utils/summary.go:23.31,24.23 1 0
github.com/muety/wakapi/utils/summary.go:25.32,26.42 1 0
github.com/muety/wakapi/utils/summary.go:27.33,28.43 1 0
github.com/muety/wakapi/utils/summary.go:29.35,30.43 1 0
github.com/muety/wakapi/utils/summary.go:31.26,32.21 1 0
github.com/muety/wakapi/utils/summary.go:33.10,34.39 1 0
github.com/muety/wakapi/utils/summary.go:40.73,47.56 5 0
github.com/muety/wakapi/utils/summary.go:61.2,68.8 2 0
github.com/muety/wakapi/utils/summary.go:47.56,49.3 1 0
github.com/muety/wakapi/utils/summary.go:49.8,51.17 2 0
github.com/muety/wakapi/utils/summary.go:55.3,56.17 2 0
github.com/muety/wakapi/utils/summary.go:51.17,53.4 1 0
github.com/muety/wakapi/utils/summary.go:56.17,58.4 1 0
github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0
github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0
github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0
github.com/muety/wakapi/utils/auth.go:18.79,20.54 2 0
github.com/muety/wakapi/utils/auth.go:24.2,26.16 3 0
github.com/muety/wakapi/utils/auth.go:30.2,32.45 3 0
github.com/muety/wakapi/utils/auth.go:35.2,36.32 2 0
github.com/muety/wakapi/utils/auth.go:20.54,22.3 1 0
github.com/muety/wakapi/utils/auth.go:26.16,28.3 1 0
github.com/muety/wakapi/utils/auth.go:32.45,34.3 1 0
github.com/muety/wakapi/utils/auth.go:39.65,41.54 2 0
github.com/muety/wakapi/utils/auth.go:45.2,46.30 2 0
github.com/muety/wakapi/utils/auth.go:41.54,43.3 1 0
github.com/muety/wakapi/utils/auth.go:49.97,51.16 2 0
github.com/muety/wakapi/utils/auth.go:55.2,55.104 1 0
github.com/muety/wakapi/utils/auth.go:59.2,59.19 1 0
github.com/muety/wakapi/utils/auth.go:51.16,53.3 1 0
github.com/muety/wakapi/utils/auth.go:55.104,57.3 1 0
github.com/muety/wakapi/utils/auth.go:62.30,64.2 1 0
github.com/muety/wakapi/utils/auth.go:66.56,70.2 3 0
github.com/muety/wakapi/utils/auth.go:73.53,75.2 1 0
github.com/muety/wakapi/utils/auth.go:77.55,80.16 3 0
github.com/muety/wakapi/utils/auth.go:83.2,83.16 1 0
github.com/muety/wakapi/utils/auth.go:80.16,82.3 1 0
github.com/muety/wakapi/utils/auth.go:86.43,91.2 4 0
github.com/muety/wakapi/middlewares/authenticate.go:27.116,34.2 1 1
github.com/muety/wakapi/middlewares/authenticate.go:36.71,37.71 1 0
github.com/muety/wakapi/middlewares/authenticate.go:37.71,39.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:42.107,43.37 1 0
github.com/muety/wakapi/middlewares/authenticate.go:50.2,53.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:57.2,57.16 1 0
github.com/muety/wakapi/middlewares/authenticate.go:67.2,70.29 3 0
github.com/muety/wakapi/middlewares/authenticate.go:43.37,44.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:44.58,47.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:53.16,55.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:57.16,58.44 1 0
github.com/muety/wakapi/middlewares/authenticate.go:64.3,64.9 1 0
github.com/muety/wakapi/middlewares/authenticate.go:58.44,60.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:60.9,63.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:73.92,75.16 2 1
github.com/muety/wakapi/middlewares/authenticate.go:79.2,82.9 4 1
github.com/muety/wakapi/middlewares/authenticate.go:90.2,90.18 1 1
github.com/muety/wakapi/middlewares/authenticate.go:75.16,77.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:82.9,84.17 2 1
github.com/muety/wakapi/middlewares/authenticate.go:84.17,86.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:87.8,89.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:93.92,95.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:99.2,101.8 2 0
github.com/muety/wakapi/middlewares/authenticate.go:105.2,106.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:110.2,110.88 1 0
github.com/muety/wakapi/middlewares/authenticate.go:114.2,114.18 1 0
github.com/muety/wakapi/middlewares/authenticate.go:95.16,97.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:101.8,103.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:106.16,108.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:110.88,112.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:118.127,119.32 1 0
github.com/muety/wakapi/middlewares/authenticate.go:127.2,127.65 1 0
github.com/muety/wakapi/middlewares/authenticate.go:119.32,120.58 1 0
github.com/muety/wakapi/middlewares/authenticate.go:125.3,125.15 1 0
github.com/muety/wakapi/middlewares/authenticate.go:120.58,124.4 3 0
github.com/muety/wakapi/middlewares/logging.go:11.48,13.2 1 0
github.com/muety/wakapi/middlewares/logging.go:15.66,17.2 1 0
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0
github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0
github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
github.com/muety/wakapi/services/aggregation.go:51.67,55.40 3 0
github.com/muety/wakapi/services/aggregation.go:59.2,59.50 1 0
github.com/muety/wakapi/services/aggregation.go:64.2,64.60 1 0
github.com/muety/wakapi/services/aggregation.go:70.2,70.35 1 0
github.com/muety/wakapi/services/aggregation.go:55.40,57.3 1 0
github.com/muety/wakapi/services/aggregation.go:59.50,61.3 1 0
github.com/muety/wakapi/services/aggregation.go:64.60,68.3 3 0
github.com/muety/wakapi/services/aggregation.go:73.109,74.24 1 0
github.com/muety/wakapi/services/aggregation.go:74.24,75.111 1 0
github.com/muety/wakapi/services/aggregation.go:75.111,77.4 1 0
github.com/muety/wakapi/services/aggregation.go:77.9,80.4 2 0
github.com/muety/wakapi/services/aggregation.go:84.80,85.33 1 0
github.com/muety/wakapi/services/aggregation.go:85.33,86.60 1 0
github.com/muety/wakapi/services/aggregation.go:86.60,88.4 1 0
github.com/muety/wakapi/services/aggregation.go:92.100,96.59 3 0
github.com/muety/wakapi/services/aggregation.go:111.2,112.16 2 0
github.com/muety/wakapi/services/aggregation.go:118.2,119.16 2 0
github.com/muety/wakapi/services/aggregation.go:125.2,126.44 2 0
github.com/muety/wakapi/services/aggregation.go:131.2,131.41 1 0
github.com/muety/wakapi/services/aggregation.go:145.2,145.12 1 0
github.com/muety/wakapi/services/aggregation.go:96.59,99.3 2 0
github.com/muety/wakapi/services/aggregation.go:99.8,99.47 1 0
github.com/muety/wakapi/services/aggregation.go:99.47,101.30 2 0
github.com/muety/wakapi/services/aggregation.go:101.30,102.43 1 0
github.com/muety/wakapi/services/aggregation.go:102.43,104.5 1 0
github.com/muety/wakapi/services/aggregation.go:106.8,108.3 1 0
github.com/muety/wakapi/services/aggregation.go:112.16,115.3 2 0
github.com/muety/wakapi/services/aggregation.go:119.16,122.3 2 0
github.com/muety/wakapi/services/aggregation.go:126.44,128.3 1 0
github.com/muety/wakapi/services/aggregation.go:131.41,132.21 1 0
github.com/muety/wakapi/services/aggregation.go:132.21,136.4 1 0
github.com/muety/wakapi/services/aggregation.go:136.9,136.62 1 0
github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0
github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0
github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0
github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0
github.com/muety/wakapi/services/alias.go:16.77,21.2 1 1
github.com/muety/wakapi/services/alias.go:25.63,27.16 2 1
github.com/muety/wakapi/services/alias.go:30.2,30.12 1 1
github.com/muety/wakapi/services/alias.go:27.16,29.3 1 1
github.com/muety/wakapi/services/alias.go:33.108,34.32 1 1
github.com/muety/wakapi/services/alias.go:40.2,41.46 2 1
github.com/muety/wakapi/services/alias.go:46.2,46.19 1 1
github.com/muety/wakapi/services/alias.go:34.32,35.53 1 1
github.com/muety/wakapi/services/alias.go:35.53,37.4 1 1
github.com/muety/wakapi/services/alias.go:41.46,42.48 1 1
github.com/muety/wakapi/services/alias.go:42.48,44.4 1 1
github.com/muety/wakapi/services/alias.go:49.60,50.43 1 1
github.com/muety/wakapi/services/alias.go:53.2,53.14 1 1
github.com/muety/wakapi/services/alias.go:50.43,52.3 1 1
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
github.com/muety/wakapi/services/heartbeat.go:25.80,27.2 1 0
github.com/muety/wakapi/services/heartbeat.go:29.111,31.16 2 0
github.com/muety/wakapi/services/heartbeat.go:34.2,34.43 1 0
github.com/muety/wakapi/services/heartbeat.go:31.16,33.3 1 0
github.com/muety/wakapi/services/heartbeat.go:37.78,39.2 1 0
github.com/muety/wakapi/services/heartbeat.go:41.62,43.2 1 0
github.com/muety/wakapi/services/heartbeat.go:45.116,47.16 2 0
github.com/muety/wakapi/services/heartbeat.go:51.2,51.28 1 0
github.com/muety/wakapi/services/heartbeat.go:55.2,55.24 1 0
github.com/muety/wakapi/services/heartbeat.go:47.16,49.3 1 0
github.com/muety/wakapi/services/heartbeat.go:51.28,53.3 1 0
github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0
github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0
github.com/muety/wakapi/services/key_value.go:25.72,27.2 1 0
github.com/muety/wakapi/services/key_value.go:29.60,31.2 1 0
github.com/muety/wakapi/services/language_mapping.go:17.118,23.2 1 0
github.com/muety/wakapi/services/language_mapping.go:25.86,27.2 1 0
github.com/muety/wakapi/services/language_mapping.go:29.96,30.53 1 0
github.com/muety/wakapi/services/language_mapping.go:34.2,35.16 2 0
github.com/muety/wakapi/services/language_mapping.go:38.2,39.22 2 0
github.com/muety/wakapi/services/language_mapping.go:30.53,32.3 1 0
github.com/muety/wakapi/services/language_mapping.go:35.16,37.3 1 0
github.com/muety/wakapi/services/language_mapping.go:42.92,45.16 3 0
github.com/muety/wakapi/services/language_mapping.go:49.2,49.33 1 0
github.com/muety/wakapi/services/language_mapping.go:52.2,52.22 1 0
github.com/muety/wakapi/services/language_mapping.go:45.16,47.3 1 0
github.com/muety/wakapi/services/language_mapping.go:49.33,51.3 1 0
github.com/muety/wakapi/services/language_mapping.go:55.109,57.16 2 0
github.com/muety/wakapi/services/language_mapping.go:61.2,62.20 2 0
github.com/muety/wakapi/services/language_mapping.go:57.16,59.3 1 0
github.com/muety/wakapi/services/language_mapping.go:65.82,69.2 3 0
github.com/muety/wakapi/services/language_mapping.go:71.74,74.2 1 0
github.com/muety/wakapi/services/summary.go:27.149,35.2 1 1
github.com/muety/wakapi/services/summary.go:39.120,42.52 2 1
github.com/muety/wakapi/services/summary.go:47.2,47.44 1 1
github.com/muety/wakapi/services/summary.go:53.2,53.66 1 1
github.com/muety/wakapi/services/summary.go:58.2,59.16 2 1
github.com/muety/wakapi/services/summary.go:64.2,66.30 3 1
github.com/muety/wakapi/services/summary.go:42.52,44.3 1 0
github.com/muety/wakapi/services/summary.go:47.44,50.3 2 1
github.com/muety/wakapi/services/summary.go:53.66,55.3 1 0
github.com/muety/wakapi/services/summary.go:59.16,61.3 1 0
github.com/muety/wakapi/services/summary.go:69.101,72.52 2 1
github.com/muety/wakapi/services/summary.go:77.2,78.16 2 1
github.com/muety/wakapi/services/summary.go:83.2,84.44 2 1
github.com/muety/wakapi/services/summary.go:93.2,94.16 2 1
github.com/muety/wakapi/services/summary.go:99.2,100.30 2 1
github.com/muety/wakapi/services/summary.go:72.52,74.3 1 0
github.com/muety/wakapi/services/summary.go:78.16,80.3 1 0
github.com/muety/wakapi/services/summary.go:84.44,85.78 1 1
github.com/muety/wakapi/services/summary.go:85.78,87.4 1 1
github.com/muety/wakapi/services/summary.go:87.9,89.4 1 0
github.com/muety/wakapi/services/summary.go:94.16,96.3 1 0
github.com/muety/wakapi/services/summary.go:103.102,106.89 2 1
github.com/muety/wakapi/services/summary.go:112.2,116.26 4 1
github.com/muety/wakapi/services/summary.go:121.2,127.34 6 1
github.com/muety/wakapi/services/summary.go:143.2,143.26 1 1
github.com/muety/wakapi/services/summary.go:148.2,161.30 2 1
github.com/muety/wakapi/services/summary.go:106.89,108.3 1 1
github.com/muety/wakapi/services/summary.go:108.8,110.3 1 0
github.com/muety/wakapi/services/summary.go:116.26,118.3 1 1
github.com/muety/wakapi/services/summary.go:127.34,129.20 2 1
github.com/muety/wakapi/services/summary.go:130.30,131.29 1 1
github.com/muety/wakapi/services/summary.go:132.31,133.30 1 1
github.com/muety/wakapi/services/summary.go:134.29,135.28 1 1
github.com/muety/wakapi/services/summary.go:136.25,137.24 1 1
github.com/muety/wakapi/services/summary.go:138.30,139.29 1 1
github.com/muety/wakapi/services/summary.go:143.26,146.3 2 1
github.com/muety/wakapi/services/summary.go:166.76,168.2 1 0
github.com/muety/wakapi/services/summary.go:170.62,172.2 1 0
github.com/muety/wakapi/services/summary.go:174.66,176.2 1 0
github.com/muety/wakapi/services/summary.go:180.127,183.31 2 1
github.com/muety/wakapi/services/summary.go:206.2,207.30 2 1
github.com/muety/wakapi/services/summary.go:215.2,215.40 1 1
github.com/muety/wakapi/services/summary.go:219.2,219.67 1 1
github.com/muety/wakapi/services/summary.go:183.31,186.35 2 1
github.com/muety/wakapi/services/summary.go:190.3,190.13 1 1
github.com/muety/wakapi/services/summary.go:194.3,199.27 2 1
github.com/muety/wakapi/services/summary.go:203.3,203.26 1 1
github.com/muety/wakapi/services/summary.go:186.35,188.4 1 1
github.com/muety/wakapi/services/summary.go:190.13,191.12 1 1
github.com/muety/wakapi/services/summary.go:199.27,202.4 2 1
github.com/muety/wakapi/services/summary.go:207.30,213.3 1 1
github.com/muety/wakapi/services/summary.go:215.40,217.3 1 1
github.com/muety/wakapi/services/summary.go:222.97,223.24 1 1
github.com/muety/wakapi/services/summary.go:227.2,239.30 4 1
github.com/muety/wakapi/services/summary.go:259.2,262.26 3 1
github.com/muety/wakapi/services/summary.go:223.24,225.3 1 0
github.com/muety/wakapi/services/summary.go:239.30,240.38 1 1
github.com/muety/wakapi/services/summary.go:244.3,244.37 1 1
github.com/muety/wakapi/services/summary.go:248.3,248.34 1 1
github.com/muety/wakapi/services/summary.go:252.3,256.83 5 1
github.com/muety/wakapi/services/summary.go:240.38,242.4 1 0
github.com/muety/wakapi/services/summary.go:244.37,246.4 1 1
github.com/muety/wakapi/services/summary.go:248.34,250.4 1 1
github.com/muety/wakapi/services/summary.go:265.127,269.32 2 1
github.com/muety/wakapi/services/summary.go:273.2,273.27 1 1
github.com/muety/wakapi/services/summary.go:281.2,283.26 3 1
github.com/muety/wakapi/services/summary.go:288.2,288.43 1 1
github.com/muety/wakapi/services/summary.go:292.2,292.17 1 1
github.com/muety/wakapi/services/summary.go:269.32,271.3 1 1
github.com/muety/wakapi/services/summary.go:273.27,274.37 1 1
github.com/muety/wakapi/services/summary.go:274.37,276.4 1 1
github.com/muety/wakapi/services/summary.go:276.9,278.4 1 1
github.com/muety/wakapi/services/summary.go:283.26,286.3 2 1
github.com/muety/wakapi/services/summary.go:288.43,290.3 1 1
github.com/muety/wakapi/services/summary.go:295.116,296.25 1 1
github.com/muety/wakapi/services/summary.go:300.2,303.44 2 1
github.com/muety/wakapi/services/summary.go:308.2,308.40 1 1
github.com/muety/wakapi/services/summary.go:324.2,324.54 1 1
github.com/muety/wakapi/services/summary.go:328.2,328.18 1 1
github.com/muety/wakapi/services/summary.go:296.25,298.3 1 0
github.com/muety/wakapi/services/summary.go:303.44,305.3 1 1
github.com/muety/wakapi/services/summary.go:308.40,310.19 2 1
github.com/muety/wakapi/services/summary.go:315.3,318.22 3 0
github.com/muety/wakapi/services/summary.go:310.19,311.12 1 1
github.com/muety/wakapi/services/summary.go:318.22,320.4 1 0
github.com/muety/wakapi/services/summary.go:324.54,326.3 1 1
github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1
github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1
github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1
github.com/muety/wakapi/services/user.go:16.73,21.2 1 0
github.com/muety/wakapi/services/user.go:23.74,25.2 1 0
github.com/muety/wakapi/services/user.go:27.72,29.2 1 0
github.com/muety/wakapi/services/user.go:31.58,33.2 1 0
github.com/muety/wakapi/services/user.go:35.88,42.93 2 0
github.com/muety/wakapi/services/user.go:48.2,48.38 1 0
github.com/muety/wakapi/services/user.go:42.93,44.3 1 0
github.com/muety/wakapi/services/user.go:44.8,46.3 1 0
github.com/muety/wakapi/services/user.go:51.73,53.2 1 0
github.com/muety/wakapi/services/user.go:55.78,58.2 2 0
github.com/muety/wakapi/services/user.go:60.79,62.2 1 0
github.com/muety/wakapi/services/user.go:64.106,66.96 2 0
github.com/muety/wakapi/services/user.go:71.2,71.68 1 0
github.com/muety/wakapi/services/user.go:66.96,68.3 1 0
github.com/muety/wakapi/services/user.go:68.8,70.3 1 0

29
docs/advanced_setup.md Normal file
View File

@ -0,0 +1,29 @@
# Advanced Setup
## Optional: Client-side proxy
Most Wakatime plugins work in a way that, for every heartbeat to send, the plugin calls your local [wakatime-cli](https://github.com/wakatime/wakatime) (a small Python program that is automatically installed when installing a Wakatime plugin) with a few command-line arguments, which is then run as a new process. Inside that process, a heartbeat request is forged and sent to the backend API Wakapi in this case.
While this is convenient for plugin developers, as they do not have to deal with sending HTTP requests, etc., it comes with a minor drawback. Because the CLI process shuts down after each request, its TCP connection is closed as well. Accordingly, **TCP connections cannot be re-used** and every single heartbeat request is inevitably preceded by the `SYN` + `SYN ACK` + `ACK` sequence for establishing a new TCP connection as well as a handshake for establishing a new TLS session.
While this certainly does not hurt, it is still a bit of overhead. You can avoid that by setting up a local reverse proxy on your machine, that keeps running as a daemon and can therefore keep a continuous connection.
In this example, [Caddy](https://caddyserver.com) is used as an easy-to-set-up webserver / reverse proxy.
1. [Install Caddy](https://caddyserver.com/)
* When installing manually, don't forget to set up a systemd service to start Caddy on system startup
1. Create a Caddyfile
```
# /etc/caddy/Caddyfile
http://localhost:8070 {
reverse_proxy * {
to https://wakapi.dev # <-- substitute your own Wakapi host here
header_up Host {http.reverse_proxy.upstream.host}
header_down -Server
}
}
```
1. Restart Caddy
1. Verify that you can access [`http://localhost:8070/api/health`](http://localhost:8070/api/health)
1. Update `~/.wakatime.cfg`
* Set `api_url = http://localhost:8070/api/heartbeat`
1. Done

3
go.mod
View File

@ -3,11 +3,11 @@ module github.com/muety/wakapi
go 1.13 go 1.13
require ( require (
github.com/go-co-op/gocron v0.3.3
github.com/gorilla/handlers v1.4.2 github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.3 github.com/gorilla/mux v1.7.3
github.com/gorilla/schema v1.1.0 github.com/gorilla/schema v1.1.0
github.com/gorilla/securecookie v1.1.1 github.com/gorilla/securecookie v1.1.1
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
github.com/jinzhu/configor v1.2.0 github.com/jinzhu/configor v1.2.0
github.com/joho/godotenv v1.3.0 github.com/joho/godotenv v1.3.0
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
@ -16,6 +16,7 @@ require (
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
github.com/satori/go.uuid v1.2.0 github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.6.1
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/ini.v1 v1.50.0 gopkg.in/ini.v1 v1.50.0

10
go.sum
View File

@ -64,6 +64,8 @@ github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVB
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs=
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
@ -204,8 +206,6 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0=
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA= github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc= github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -382,12 +382,15 @@ github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
@ -452,6 +455,7 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -554,6 +558,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg= gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI= gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw= gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=

113
main.go
View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/migrations/common" "github.com/muety/wakapi/migrations/common"
@ -28,22 +29,22 @@ var (
) )
var ( var (
aliasRepository *repositories.AliasRepository aliasRepository repositories.IAliasRepository
heartbeatRepository *repositories.HeartbeatRepository heartbeatRepository repositories.IHeartbeatRepository
userRepository *repositories.UserRepository userRepository repositories.IUserRepository
languageMappingRepository *repositories.LanguageMappingRepository languageMappingRepository repositories.ILanguageMappingRepository
summaryRepository *repositories.SummaryRepository summaryRepository repositories.ISummaryRepository
keyValueRepository *repositories.KeyValueRepository keyValueRepository repositories.IKeyValueRepository
) )
var ( var (
aliasService *services.AliasService aliasService services.IAliasService
heartbeatService *services.HeartbeatService heartbeatService services.IHeartbeatService
userService *services.UserService userService services.IUserService
languageMappingService *services.LanguageMappingService languageMappingService services.ILanguageMappingService
summaryService *services.SummaryService summaryService services.ISummaryService
aggregationService *services.AggregationService aggregationService services.IAggregationService
keyValueService *services.KeyValueService keyValueService services.IKeyValueService
) )
// TODO: Refactor entire project to be structured after business domains // TODO: Refactor entire project to be structured after business domains
@ -102,12 +103,15 @@ func main() {
// TODO: move endpoint registration to the respective routes files // TODO: move endpoint registration to the respective routes files
routes.Init()
// Handlers // Handlers
summaryHandler := routes.NewSummaryHandler(summaryService) summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db) healthHandler := routes.NewHealthHandler(db)
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService) heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService) settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
homeHandler := routes.NewHomeHandler(userService) homeHandler := routes.NewHomeHandler()
loginHandler := routes.NewLoginHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService) imprintHandler := routes.NewImprintHandler(keyValueService)
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService) wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService) wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
@ -140,10 +144,11 @@ func main() {
// Public Routes // Public Routes
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex) publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogin) publicRouter.Path("/login").Methods(http.MethodGet).HandlerFunc(loginHandler.GetIndex)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogout) publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogin)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(homeHandler.GetSignup) publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(homeHandler.PostSignup) publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(loginHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(loginHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint) publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
// Summary Routes // Summary Routes
@ -174,15 +179,71 @@ func main() {
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
// Listen HTTP // Listen HTTP
portString := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port) listen(router)
s := &http.Server{ }
Handler: router,
Addr: portString, func listen(handler http.Handler) {
ReadTimeout: 10 * time.Second, var s4, s6 *http.Server
WriteTimeout: 10 * time.Second,
// IPv4
if config.Server.ListenIpV4 != "" {
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
s4 = &http.Server{
Handler: handler,
Addr: bindString4,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
} }
log.Printf("Listening on %+s\n", portString)
s.ListenAndServe() // IPv6
if config.Server.ListenIpV6 != "" {
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
s6 = &http.Server{
Handler: handler,
Addr: bindString6,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
}
if config.UseTLS() {
if s4 != nil {
fmt.Printf("Listening for HTTPS on %s.\n", s4.Addr)
go func() {
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
log.Fatalln(err)
}
}()
}
if s6 != nil {
fmt.Printf("Listening for HTTPS on %s.\n", s6.Addr)
go func() {
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
log.Fatalln(err)
}
}()
}
} else {
if s4 != nil {
fmt.Printf("Listening for HTTP on %s.\n", s4.Addr)
go func() {
if err := s4.ListenAndServe(); err != nil {
log.Fatalln(err)
}
}()
}
if s6 != nil {
fmt.Printf("Listening for HTTP on %s.\n", s6.Addr)
go func() {
if err := s6.ListenAndServe(); err != nil {
log.Fatalln(err)
}
}()
}
}
<-make(chan interface{}, 1)
} }
func runDatabaseMigrations() { func runDatabaseMigrations() {

View File

@ -19,12 +19,12 @@ import (
type AuthenticateMiddleware struct { type AuthenticateMiddleware struct {
config *conf.Config config *conf.Config
userSrvc *services.UserService
cache *cache.Cache cache *cache.Cache
userSrvc services.IUserService
whitelistPaths []string whitelistPaths []string
} }
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware { func NewAuthenticateMiddleware(userService services.IUserService, whitelistPaths []string) *AuthenticateMiddleware {
return &AuthenticateMiddleware{ return &AuthenticateMiddleware{
config: conf.Get(), config: conf.Get(),
userSrvc: userService, userSrvc: userService,
@ -58,7 +58,7 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
if strings.HasPrefix(r.URL.Path, "/api") { if strings.HasPrefix(r.URL.Path, "/api") {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
} else { } else {
utils.ClearCookie(w, models.AuthCookieKey, !m.config.Security.InsecureCookies) 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, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
} }
return return
@ -107,7 +107,7 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
return nil, err return nil, err
} }
if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, m.userSrvc) { if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, &m.userSrvc) {
return nil, errors.New("invalid password") return nil, errors.New("invalid password")
} }
@ -115,11 +115,11 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
} }
// migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility // migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool { func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.IUserService) bool {
if utils.IsMd5(user.Password) { if utils.IsMd5(user.Password) {
if utils.CompareMd5(user.Password, login.Password, "") { if utils.CompareMd5(user.Password, login.Password, "") {
log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID) log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
userServiceRef.MigrateMd5Password(user, login) (*userServiceRef).MigrateMd5Password(user, login)
return true return true
} }
return false return false

View File

@ -0,0 +1,81 @@
package middlewares
import (
"encoding/base64"
"fmt"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"testing"
)
func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
testUser := &models.User{ApiKey: testApiKey}
mockRequest := &http.Request{
Header: http.Header{
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
result, err := sut.tryGetUserByApiKey(mockRequest)
assert.Nil(t, err)
assert.Equal(t, testUser, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKey_GetFromCache(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
testUser := &models.User{ApiKey: testApiKey}
mockRequest := &http.Request{
Header: http.Header{
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
sut.cache.SetDefault(testApiKey, testUser)
result, err := sut.tryGetUserByApiKey(mockRequest)
assert.Nil(t, err)
assert.Equal(t, testUser, result)
userServiceMock.AssertNotCalled(t, "GetUserByKey", mock.Anything)
}
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
mockRequest := &http.Request{
Header: http.Header{
// 'Basic' prefix missing here
"Authorization": []string{fmt.Sprintf("%s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
result, err := sut.tryGetUserByApiKey(mockRequest)
assert.Error(t, err)
assert.Nil(t, result)
}
// TODO: somehow test cookie auth function

View File

@ -22,7 +22,7 @@ func init() {
func RunCustomPostMigrations(db *gorm.DB, cfg *config.Config) { func RunCustomPostMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range customPostMigrations { for _, m := range customPostMigrations {
log.Printf("running migration '%s'\n", m.name) log.Printf("potentially running migration '%s'\n", m.name)
if err := m.f(db, cfg); err != nil { if err := m.f(db, cfg); err != nil {
log.Fatalf("migration '%s' failed %v\n", m.name, err) log.Fatalf("migration '%s' failed %v\n", m.name, err)
} }

View File

@ -100,7 +100,7 @@ func init() {
func RunCustomPreMigrations(db *gorm.DB, cfg *config.Config) { func RunCustomPreMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range customPreMigrations { for _, m := range customPreMigrations {
log.Printf("running migration '%s'\n", m.name) log.Printf("potentially running migration '%s'\n", m.name)
if err := m.f(db, cfg); err != nil { if err := m.f(db, cfg); err != nil {
log.Fatalf("migration '%s' failed %v\n", m.name, err) log.Fatalf("migration '%s' failed %v\n", m.name, err)
} }

15
mocks/alias_repository.go Normal file
View File

@ -0,0 +1,15 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type AliasRepositoryMock struct {
mock.Mock
}
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
args := m.Called(s)
return args.Get(0).([]*models.Alias), args.Error(1)
}

24
mocks/alias_service.go Normal file
View File

@ -0,0 +1,24 @@
package mocks
import (
"github.com/stretchr/testify/mock"
)
type AliasServiceMock struct {
mock.Mock
}
func (m *AliasServiceMock) LoadUserAliases(s string) error {
args := m.Called(s)
return args.Error(0)
}
func (m *AliasServiceMock) GetAliasOrDefault(s string, u uint8, s2 string) (string, error) {
args := m.Called(s, u, s2)
return args.String(0), args.Error(1)
}
func (m *AliasServiceMock) IsInitialized(s string) bool {
args := m.Called(s)
return args.Bool(0)
}

View File

@ -0,0 +1,31 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"time"
)
type HeartbeatServiceMock struct {
mock.Mock
}
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
args := m.Called(heartbeats)
return args.Error(0)
}
func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
args := m.Called(time, time2, user)
return args.Get(0).([]*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
args := m.Called(time)
return args.Error(0)
}

View File

@ -0,0 +1,31 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"time"
)
type SummaryRepositoryMock struct {
mock.Mock
}
func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
args := m.Called(summary)
return args.Error(0)
}
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
args := m.Called(user, time, time2)
return args.Get(0).([]*models.Summary), args.Error(1)
}
func (m *SummaryRepositoryMock) GetLastByUser() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)
}
func (m *SummaryRepositoryMock) DeleteByUser(s string) error {
args := m.Called(s)
return args.Error(0)
}

50
mocks/user_service.go Normal file
View File

@ -0,0 +1,50 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type UserServiceMock struct {
mock.Mock
}
func (m *UserServiceMock) GetUserById(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetUserByKey(s string) (*models.User, error) {
args := m.Called(s)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
args := m.Called()
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
args := m.Called(signup)
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
}
func (m *UserServiceMock) Update(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) ResetApiKey(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error) {
args := m.Called(user)
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
args := m.Called(user, login)
return args.Get(0).(*models.User), args.Error(1)
}

View File

@ -18,13 +18,13 @@ func NewFiltersWith(entity uint8, key string) *Filters {
case SummaryProject: case SummaryProject:
return &Filters{Project: key} return &Filters{Project: key}
case SummaryOS: case SummaryOS:
return &Filters{Project: key} return &Filters{OS: key}
case SummaryLanguage: case SummaryLanguage:
return &Filters{Project: key} return &Filters{Language: key}
case SummaryEditor: case SummaryEditor:
return &Filters{Project: key} return &Filters{Editor: key}
case SummaryMachine: case SummaryMachine:
return &Filters{Project: key} return &Filters{Machine: key}
} }
return &Filters{} return &Filters{}
} }

View File

@ -24,7 +24,7 @@ type Heartbeat struct {
} }
func (h *Heartbeat) Valid() bool { func (h *Heartbeat) Valid() bool {
return h.User != nil && h.UserID != "" && h.Time != CustomTime(time.Time{}) return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
} }
func (h *Heartbeat) Augment(languageMappings map[string]string) { func (h *Heartbeat) Augment(languageMappings map[string]string) {
@ -41,3 +41,24 @@ func (h *Heartbeat) Augment(languageMappings map[string]string) {
} }
h.Language, _ = languageMappings[ending] h.Language, _ = languageMappings[ending]
} }
func (h *Heartbeat) GetKey(t uint8) (key string) {
switch t {
case SummaryProject:
key = h.Project
case SummaryEditor:
key = h.Editor
case SummaryLanguage:
key = h.Language
case SummaryOS:
key = h.OperatingSystem
case SummaryMachine:
key = h.Machine
}
if key == "" {
key = UnknownSummaryKey
}
return key
}

53
models/heartbeat_test.go Normal file
View File

@ -0,0 +1,53 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestHeartbeat_Valid_Success(t *testing.T) {
sut := &Heartbeat{
User: &User{
ID: "johndoe@example.org",
},
UserID: "johndoe@example.org",
Time: CustomTime(time.Now()),
}
assert.True(t, sut.Valid())
}
func TestHeartbeat_Valid_MissingUser(t *testing.T) {
sut := &Heartbeat{
Time: CustomTime(time.Now()),
}
assert.False(t, sut.Valid())
}
func TestHeartbeat_Augment(t *testing.T) {
testMappings := map[string]string{
"py": "Python3",
}
sut := &Heartbeat{
Entity: "~/dev/file.py",
Language: "Python",
}
sut.Augment(testMappings)
assert.Equal(t, "Python3", sut.Language)
}
func TestHeartbeat_GetKey(t *testing.T) {
sut := &Heartbeat{
Project: "wakapi",
}
assert.Equal(t, "wakapi", sut.GetKey(SummaryProject))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryOS))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryMachine))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryLanguage))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryEditor))
assert.Equal(t, UnknownSummaryKey, sut.GetKey(255))
}

38
models/heartbeats.go Normal file
View File

@ -0,0 +1,38 @@
package models
import "sort"
type Heartbeats []*Heartbeat
func (h Heartbeats) Len() int {
return len(h)
}
func (h Heartbeats) Less(i, j int) bool {
return h[i].Time.T().Before(h[j].Time.T())
}
func (h Heartbeats) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h *Heartbeats) Sorted() *Heartbeats {
sort.Sort(h)
return h
}
func (h *Heartbeats) First() *Heartbeat {
// assumes slice to be sorted
if h.Len() == 0 {
return nil
}
return (*h)[0]
}
func (h *Heartbeats) Last() *Heartbeat {
// assumes slice to be sorted
if h.Len() == 0 {
return nil
}
return (*h)[h.Len()-1]
}

View File

@ -24,6 +24,11 @@ type KeyStringValue struct {
Value string `gorm:"type:text"` Value string `gorm:"type:text"`
} }
type Interval struct {
Start time.Time
End time.Time
}
type CustomTime time.Time type CustomTime time.Time
func (j *CustomTime) UnmarshalJSON(b []byte) error { func (j *CustomTime) UnmarshalJSON(b []byte) error {
@ -79,3 +84,7 @@ func (j CustomTime) String() string {
func (j CustomTime) T() time.Time { func (j CustomTime) T() time.Time {
return time.Time(j) return time.Time(j)
} }
func (j CustomTime) Valid() bool {
return j.T().Unix() >= 0
}

View File

@ -1,6 +1,7 @@
package models package models
import ( import (
"sort"
"time" "time"
) )
@ -34,18 +35,20 @@ func Intervals() []string {
const UnknownSummaryKey = "unknown" const UnknownSummaryKey = "unknown"
type Summary struct { type Summary struct {
ID uint `json:"-" gorm:"primary_key"` ID uint `json:"-" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"` UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"` FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"` ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
Projects []*SummaryItem `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Languages []*SummaryItem `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Languages SummaryItems `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Editors []*SummaryItem `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems []*SummaryItem `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines []*SummaryItem `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
} }
type SummaryItems []*SummaryItem
type SummaryItem struct { type SummaryItem struct {
ID uint `json:"-" gorm:"primary_key"` ID uint `json:"-" gorm:"primary_key"`
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
@ -75,16 +78,27 @@ type SummaryParams struct {
Recompute bool Recompute bool
} }
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 { func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine} return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
} }
func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.Projects))
sort.Sort(sort.Reverse(s.Machines))
sort.Sort(sort.Reverse(s.OperatingSystems))
sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors))
return s
}
func (s *Summary) Types() []uint8 { func (s *Summary) Types() []uint8 {
return SummaryTypes() return SummaryTypes()
} }
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem { func (s *Summary) MappedItems() map[uint8]*SummaryItems {
return map[uint8]*[]*SummaryItem{ return map[uint8]*SummaryItems{
SummaryProject: &s.Projects, SummaryProject: &s.Projects,
SummaryLanguage: &s.Languages, SummaryLanguage: &s.Languages,
SummaryEditor: &s.Editors, SummaryEditor: &s.Editors,
@ -178,3 +192,65 @@ func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
} }
return timeSum return timeSum
} }
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
processAliases := func(origin []*SummaryItem) []*SummaryItem {
target := make([]*SummaryItem, 0)
findItem := func(key string) *SummaryItem {
for _, item := range target {
if item.Key == key {
return item
}
}
return nil
}
for _, item := range origin {
// Add all "top-level" items, i.e. such without aliases
if key := resolve(item.Type, item.Key); key == item.Key {
target = append(target, item)
}
}
for _, item := range origin {
// Add all remaining projects and merge with their alias
if key := resolve(item.Type, item.Key); key != item.Key {
if targetItem := findItem(key); targetItem != nil {
targetItem.Total += item.Total
} else {
target = append(target, &SummaryItem{
ID: item.ID,
SummaryID: item.SummaryID,
Type: item.Type,
Key: key,
Total: item.Total,
})
}
}
}
return target
}
// Resolve aliases
s.Projects = processAliases(s.Projects)
s.Editors = processAliases(s.Editors)
s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines)
return s
}
func (s SummaryItems) Len() int {
return len(s)
}
func (s SummaryItems) Less(i, j int) bool {
return s[i].Total < s[j].Total
}
func (s SummaryItems) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}

195
models/summary_test.go Normal file
View File

@ -0,0 +1,195 @@
package models
import (
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestSummary_FillUnknown(t *testing.T) {
testDuration := 10 * time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration / time.Second,
},
},
}
sut.FillUnknown()
itemLists := [][]*SummaryItem{
sut.Machines,
sut.OperatingSystems,
sut.Languages,
sut.Editors,
}
for _, l := range itemLists {
assert.Len(t, l, 1)
assert.Equal(t, UnknownSummaryKey, l[0].Key)
assert.Equal(t, testDuration, l[0].Total)
}
}
func TestSummary_TotalTimeBy(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: testDuration3 / time.Second,
},
},
}
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeBy(SummaryProject))
assert.Equal(t, testDuration3, sut.TotalTimeBy(SummaryLanguage))
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
assert.Zero(t, sut.TotalTimeBy(SummaryMachine))
assert.Zero(t, sut.TotalTimeBy(SummaryOS))
}
func TestSummary_TotalTimeByFilters(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: testDuration3 / time.Second,
},
},
}
filters1 := &Filters{Project: "wakapi"}
filters2 := &Filters{Project: "wakapi", Language: "Go"} // filters have OR logic
filters3 := &Filters{}
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
assert.Equal(t, testDuration1+testDuration3, sut.TotalTimeByFilters(filters2))
assert.Zero(t, sut.TotalTimeByFilters(filters3))
}
func TestSummary_WithResolvedAliases(t *testing.T) {
testDuration1, testDuration2, testDuration3, testDuration4 := 10*time.Minute, 5*time.Minute, 1*time.Minute, 20*time.Minute
var resolver AliasResolver = func(t uint8, k string) string {
switch t {
case SummaryProject:
switch k {
case "wakapi-mobile":
return "wakapi"
}
case SummaryLanguage:
switch k {
case "Java 8":
return "Java"
}
}
return k
}
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
Total: testDuration1 / time.Second,
},
{
Type: SummaryProject,
Key: "wakapi-mobile",
Total: testDuration2 / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration3 / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Java 8",
Total: testDuration4 / time.Second,
},
},
}
sut = sut.WithResolvedAliases(resolver)
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeByKey(SummaryProject, "wakapi"))
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "wakapi-mobile"))
assert.Equal(t, testDuration3, sut.TotalTimeByKey(SummaryProject, "anchr"))
assert.Equal(t, testDuration4, sut.TotalTimeByKey(SummaryLanguage, "Java"))
assert.Zero(t, sut.TotalTimeByKey(SummaryLanguage, "wakapi"))
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "Java 8"))
assert.Len(t, sut.Projects, 2)
assert.Len(t, sut.Languages, 1)
assert.Empty(t, sut.Editors)
assert.Empty(t, sut.OperatingSystems)
assert.Empty(t, sut.Machines)
}
func TestSummaryItems_Sorted(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
sut := &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
Total: testDuration1,
},
{
Type: SummaryProject,
Key: "anchr",
Total: testDuration2,
},
{
Type: SummaryProject,
Key: "anchr-mobile",
Total: testDuration3,
},
},
}
sut = sut.Sorted()
assert.Equal(t, testDuration3, sut.Projects[0].Total)
assert.Equal(t, testDuration1, sut.Projects[1].Total)
assert.Equal(t, testDuration2, sut.Projects[2].Total)
}

View File

@ -26,6 +26,11 @@ type CredentialsReset struct {
PasswordRepeat string `schema:"password_repeat"` PasswordRepeat string `schema:"password_repeat"`
} }
type TimeByUser struct {
User string
Time CustomTime
}
func (c *CredentialsReset) IsValid() bool { func (c *CredentialsReset) IsValid() bool {
return validatePassword(c.PasswordNew) && return validatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat c.PasswordNew == c.PasswordRepeat

16
models/view/login.go Normal file
View File

@ -0,0 +1,16 @@
package view
type LoginViewModel struct {
Success string
Error string
}
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
s.Success = m
return s
}
func (s *LoginViewModel) WithError(m string) *LoginViewModel {
s.Error = m
return s
}

View File

@ -34,18 +34,14 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
return heartbeats, nil return heartbeats, nil
} }
// Will return *models.Heartbeat object with only user_id and time fields filled func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
func (r *HeartbeatRepository) GetFirstByUsers(userIds []string) ([]*models.Heartbeat, error) { var result []*models.TimeByUser
var heartbeats []*models.Heartbeat r.db.Model(&models.User{}).
if err := r.db. Select("users.id as user, min(time) as time").
Table("heartbeats"). Joins("left join heartbeats on users.id = heartbeats.user_id").
Select("user_id, min(time) as time"). Group("user").
Where("user_id IN (?)", userIds). Scan(&result)
Group("user_id"). return result, nil
Scan(&heartbeats).Error; err != nil {
return nil, err
}
return heartbeats, nil
} }
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error { func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {

View File

@ -0,0 +1,46 @@
package repositories
import (
"github.com/muety/wakapi/models"
"time"
)
type IAliasRepository interface {
GetByUser(string) ([]*models.Alias, error)
}
type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
DeleteBefore(time.Time) error
}
type IKeyValueRepository interface {
GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error
DeleteString(string) error
}
type ILanguageMappingRepository interface {
GetById(uint) (*models.LanguageMapping, error)
GetByUser(string) ([]*models.LanguageMapping, error)
Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
Delete(uint) error
}
type ISummaryRepository interface {
Insert(*models.Summary) error
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
GetLastByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
}
type IUserRepository interface {
GetById(string) (*models.User, error)
GetByApiKey(string) (*models.User, error)
GetAll() ([]*models.User, error)
InsertOrGet(*models.User) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
UpdateField(*models.User, string, interface{}) (*models.User, error)
}

View File

@ -39,17 +39,14 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
return summaries, nil return summaries, nil
} }
// Will return *models.Index objects with only user_id and to_time filled func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
func (r *SummaryRepository) GetLatestByUser() ([]*models.Summary, error) { var result []*models.TimeByUser
var summaries []*models.Summary r.db.Model(&models.User{}).
if err := r.db. Select("users.id as user, max(to_time) as time").
Table("summaries"). Joins("left join summaries on users.id = summaries.user_id").
Select("user_id, max(to_time) as to_time"). Group("user").
Group("user_id"). Scan(&result)
Scan(&summaries).Error; err != nil { return result, nil
return nil, err
}
return summaries, nil
} }
func (r *SummaryRepository) DeleteByUser(userId string) error { func (r *SummaryRepository) DeleteByUser(userId string) error {

View File

@ -2,7 +2,7 @@ package v1
import ( import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1" v1 "github.com/muety/wakapi/models/compat/shields/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@ -18,17 +18,16 @@ const (
) )
type BadgeHandler struct { type BadgeHandler struct {
userSrvc *services.UserService config *conf.Config
summarySrvc *services.SummaryService userSrvc services.IUserService
aliasSrvc *services.AliasService summarySrvc services.ISummaryService
config *config2.Config
} }
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler { func NewBadgeHandler(summaryService services.ISummaryService, userService services.IUserService) *BadgeHandler {
return &BadgeHandler{ return &BadgeHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
userSrvc: userService, userSrvc: userService,
config: config2.Get(), config: conf.Get(),
} }
} }
@ -97,9 +96,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
User: user, User: user,
} }
summary, err := h.summarySrvc.PostProcessWrapped( var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), if summaryParams.Recompute {
) retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil { if err != nil {
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }

View File

@ -2,7 +2,7 @@ package v1
import ( import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@ -13,14 +13,14 @@ import (
) )
type AllTimeHandler struct { type AllTimeHandler struct {
summarySrvc *services.SummaryService config *conf.Config
config *config2.Config summarySrvc services.ISummaryService
} }
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler { func NewAllTimeHandler(summaryService services.ISummaryService) *AllTimeHandler {
return &AllTimeHandler{ return &AllTimeHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
config: config2.Get(), config: conf.Get(),
} }
} }
@ -55,9 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
Recompute: false, Recompute: false,
} }
summary, err := h.summarySrvc.PostProcessWrapped( var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant if summaryParams.Recompute {
) retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil { if err != nil {
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }

View File

@ -3,7 +3,7 @@ package v1
import ( import (
"errors" "errors"
"github.com/gorilla/mux" "github.com/gorilla/mux"
config2 "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1" v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@ -14,14 +14,14 @@ import (
) )
type SummariesHandler struct { type SummariesHandler struct {
summarySrvc *services.SummaryService config *conf.Config
config *config2.Config summarySrvc services.ISummaryService
} }
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler { func NewSummariesHandler(summaryService services.ISummaryService) *SummariesHandler {
return &SummariesHandler{ return &SummariesHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
config: config2.Get(), config: conf.Get(),
} }
} }
@ -86,9 +86,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
summaries := make([]*models.Summary, len(intervals)) summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals { for i, interval := range intervals {
summary, err := h.summarySrvc.PostProcessWrapped( summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
h.summarySrvc.Construct(interval[0], interval[1], user, false), // 'to' is always constant
)
if err != nil { if err != nil {
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }

View File

@ -14,11 +14,11 @@ import (
type HeartbeatHandler struct { type HeartbeatHandler struct {
config *conf.Config config *conf.Config
heartbeatSrvc *services.HeartbeatService heartbeatSrvc services.IHeartbeatService
languageMappingSrvc *services.LanguageMappingService languageMappingSrvc services.ILanguageMappingService
} }
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService, languageMappingService *services.LanguageMappingService) *HeartbeatHandler { func NewHeartbeatHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatHandler {
return &HeartbeatHandler{ return &HeartbeatHandler{
config: conf.Get(), config: conf.Get(),
heartbeatSrvc: heartbeatService, heartbeatSrvc: heartbeatService,

View File

@ -4,28 +4,21 @@ import (
"fmt" "fmt"
"github.com/gorilla/schema" "github.com/gorilla/schema"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view" "github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http" "net/http"
"net/url"
"time"
) )
type HomeHandler struct { type HomeHandler struct {
config *conf.Config config *conf.Config
userSrvc *services.UserService
} }
var loginDecoder = schema.NewDecoder() var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder() var signupDecoder = schema.NewDecoder()
func NewHomeHandler(userService *services.UserService) *HomeHandler { func NewHomeHandler() *HomeHandler {
return &HomeHandler{ return &HomeHandler{
config: conf.Get(), config: conf.Get(),
userSrvc: userService,
} }
} }
@ -42,129 +35,6 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r)) templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
} }
func (h *HomeHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var login models.Login
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
return
}
// TODO: depending on middleware package here is a hack
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, h.userSrvc) {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
return
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
user.LastLoggedInAt = models.CustomTime(time.Now())
h.userSrvc.Update(user)
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: !h.config.Security.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
}
func (h *HomeHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
utils.ClearCookie(w, models.AuthCookieKey, !h.config.Security.InsecureCookies)
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
}
func (h *HomeHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
}
func (h *HomeHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var signup models.Signup
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if !signup.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
return
}
if !created {
w.WriteHeader(http.StatusConflict)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
return
}
msg := url.QueryEscape("account created successfully")
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
}
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel { func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
return &view.HomeViewModel{ return &view.HomeViewModel{
Success: r.URL.Query().Get("success"), Success: r.URL.Query().Get("success"),

View File

@ -10,10 +10,10 @@ import (
type ImprintHandler struct { type ImprintHandler struct {
config *conf.Config config *conf.Config
keyValueSrvc *services.KeyValueService keyValueSrvc services.IKeyValueService
} }
func NewImprintHandler(keyValueService *services.KeyValueService) *ImprintHandler { func NewImprintHandler(keyValueService services.IKeyValueService) *ImprintHandler {
return &ImprintHandler{ return &ImprintHandler{
config: conf.Get(), config: conf.Get(),
keyValueSrvc: keyValueService, keyValueSrvc: keyValueService,

159
routes/login.go Normal file
View File

@ -0,0 +1,159 @@
package routes
import (
"fmt"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"net/http"
"time"
)
type LoginHandler struct {
config *conf.Config
userSrvc services.IUserService
}
func NewLoginHandler(userService services.IUserService) *LoginHandler {
return &LoginHandler{
config: conf.Get(),
userSrvc: userService,
}
}
func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r))
}
func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var login models.Login
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
w.WriteHeader(http.StatusNotFound)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
return
}
// TODO: depending on middleware package here is a hack
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, &h.userSrvc) {
w.WriteHeader(http.StatusUnauthorized)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
return
}
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
return
}
user.LastLoggedInAt = models.CustomTime(time.Now())
h.userSrvc.Update(user)
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
}
func (h *LoginHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
}
func (h *LoginHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
}
func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
return
}
var signup models.Signup
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
return
}
if !signup.IsValid() {
w.WriteHeader(http.StatusBadRequest)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
return
}
if !created {
w.WriteHeader(http.StatusConflict)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
return
}
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound)
}
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
return &view.LoginViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}
}

View File

@ -10,7 +10,7 @@ import (
"strings" "strings"
) )
func init() { func Init() {
loadTemplates() loadTemplates()
} }

View File

@ -10,21 +10,20 @@ import (
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"log" "log"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
) )
type SettingsHandler struct { type SettingsHandler struct {
config *conf.Config config *conf.Config
userSrvc *services.UserService userSrvc services.IUserService
summarySrvc *services.SummaryService summarySrvc services.ISummaryService
aggregationSrvc *services.AggregationService aggregationSrvc services.IAggregationService
languageMappingSrvc *services.LanguageMappingService languageMappingSrvc services.ILanguageMappingService
} }
var credentialsDecoder = schema.NewDecoder() var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService *services.UserService, summaryService *services.SummaryService, aggregationService *services.AggregationService, languageMappingService *services.LanguageMappingService) *SettingsHandler { func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
return &SettingsHandler{ return &SettingsHandler{
config: conf.Get(), config: conf.Get(),
summarySrvc: summaryService, summarySrvc: summaryService,
@ -99,15 +98,7 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
return return
} }
cookie := &http.Cookie{ http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: !h.config.Security.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully")) templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
} }
@ -178,7 +169,7 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
return return
} }
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey)) msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg)) templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
} }

View File

@ -10,11 +10,11 @@ import (
) )
type SummaryHandler struct { type SummaryHandler struct {
summarySrvc *services.SummaryService
config *conf.Config config *conf.Config
summarySrvc services.ISummaryService
} }
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler { func NewSummaryHandler(summaryService services.ISummaryService) *SummaryHandler {
return &SummaryHandler{ return &SummaryHandler{
summarySrvc: summaryService, summarySrvc: summaryService,
config: conf.Get(), config: conf.Get(),
@ -59,7 +59,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{ vm := models.SummaryViewModel{
Summary: summary, Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.config.App.LanguageColors, summary), LanguageColors: utils.FilterLanguageColors(h.config.App.GetLanguageColors(), summary),
ApiKey: user.ApiKey, ApiKey: user.ApiKey,
} }
@ -72,9 +72,12 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
return nil, err, http.StatusBadRequest return nil, err, http.StatusBadRequest
} }
summary, err := h.summarySrvc.PostProcessWrapped( var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant if summaryParams.Recompute {
) retrieveSummary = h.summarySrvc.Summarize
}
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
if err != nil { if err != nil {
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }

View File

@ -6,7 +6,7 @@ import (
"runtime" "runtime"
"time" "time"
"github.com/jasonlvhit/gocron" "github.com/go-co-op/gocron"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
) )
@ -16,12 +16,12 @@ const (
type AggregationService struct { type AggregationService struct {
config *config.Config config *config.Config
userService *UserService userService IUserService
summaryService *SummaryService summaryService ISummaryService
heartbeatService *HeartbeatService heartbeatService IHeartbeatService
} }
func NewAggregationService(userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService { func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
return &AggregationService{ return &AggregationService{
config: config.Get(), config: config.Get(),
userService: userService, userService: userService,
@ -43,8 +43,9 @@ func (srv *AggregationService) Schedule() {
log.Fatalf("failed to run aggregation jobs: %v\n", err) log.Fatalf("failed to run aggregation jobs: %v\n", err)
} }
gocron.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, nil) s := gocron.NewScheduler(time.Local)
<-gocron.Start() s.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, map[string]bool{})
s.StartBlocking()
} }
func (srv *AggregationService) Run(userIds map[string]bool) error { func (srv *AggregationService) Run(userIds map[string]bool) error {
@ -59,12 +60,19 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
go srv.persistWorker(summaries) go srv.persistWorker(summaries)
} }
// don't leak open channels
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
defer close(c1)
defer close(c2)
time.Sleep(1 * time.Hour)
}(jobs, summaries)
return srv.trigger(jobs, userIds) return srv.trigger(jobs, userIds)
} }
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) { func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs { for job := range jobs {
if summary, err := srv.summaryService.Construct(job.From, job.To, &models.User{ID: job.UserID}, true); err != nil { if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
log.Printf("Failed to generate summary (%v, %v, %s) %v.\n", job.From, job.To, job.UserID, err) log.Printf("Failed to generate summary (%v, %v, %s) %v.\n", job.From, job.To, job.UserID, err)
} else { } else {
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID) log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
@ -99,57 +107,59 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
users = allUsers users = allUsers
} }
latestSummaries, err := srv.summaryService.GetLatestByUser() // Get a map from user ids to the time of their latest summary or nil if none exists yet
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
if err != nil { if err != nil {
log.Println(err) log.Println(err)
return err return err
} }
userSummaryTimes := make(map[string]time.Time) // Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
for _, s := range latestSummaries { firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
userSummaryTimes[s.UserID] = s.ToTime.T() if err != nil {
log.Println(err)
return err
} }
missingUserIDs := make([]string, 0) // Build actual lookup table from it
for _, u := range users { firstUserHeartbeatLookup := make(map[string]models.CustomTime)
if _, ok := userSummaryTimes[u.ID]; !ok { for _, e := range firstUserHeartbeatTimes {
missingUserIDs = append(missingUserIDs, u.ID) firstUserHeartbeatLookup[e.User] = e.Time
}
// Generate summary aggregation jobs
for _, e := range lastUserSummaryTimes {
if e.Time.Valid() {
// Case 1: User has aggregated summaries already
// -> Spawn jobs to create summaries from their latest aggregation to now
generateUserJobs(e.User, e.Time.T(), jobs)
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
// Case 2: User has no aggregated summaries, yet, but has heartbeats
// -> Spawn jobs to create summaries from their first heartbeat to now
generateUserJobs(e.User, t.T(), jobs)
} }
} // Case 3: User doesn't have heartbeats at all
// -> Nothing to do
firstHeartbeats, err := srv.heartbeatService.GetFirstUserHeartbeats(missingUserIDs)
if err != nil {
log.Println(err)
return err
}
for id, t := range userSummaryTimes {
generateUserJobs(id, t, jobs)
}
for _, h := range firstHeartbeats {
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
} }
return nil return nil
} }
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) { func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
var from, to time.Time var to time.Time
// Go to next day of either user's first heartbeat or latest aggregation
from.Add(-1 * time.Second)
from = time.Date(
from.Year(),
from.Month(),
from.Day()+aggregateIntervalDays,
0, 0, 0, 0,
from.Location(),
)
// Iteratively aggregate per-day summaries until end of yesterday is reached
end := getStartOfToday().Add(-1 * time.Second) end := getStartOfToday().Add(-1 * time.Second)
if lastAggregation.Hour() == 0 {
from = lastAggregation
} else {
from = time.Date(
lastAggregation.Year(),
lastAggregation.Month(),
lastAggregation.Day()+aggregateIntervalDays,
0, 0, 0, 0,
lastAggregation.Location(),
)
}
for from.Before(end) && to.Before(end) { for from.Before(end) && to.Before(end) {
to = time.Date( to = time.Date(
from.Year(), from.Year(),

View File

@ -10,10 +10,10 @@ import (
type AliasService struct { type AliasService struct {
config *config.Config config *config.Config
repository *repositories.AliasRepository repository repositories.IAliasRepository
} }
func NewAliasService(aliasRepo *repositories.AliasRepository) *AliasService { func NewAliasService(aliasRepo repositories.IAliasRepository) *AliasService {
return &AliasService{ return &AliasService{
config: config.Get(), config: config.Get(),
repository: aliasRepo, repository: aliasRepo,

63
services/alias_test.go Normal file
View File

@ -0,0 +1,63 @@
package services
import (
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"testing"
)
type AliasServiceTestSuite struct {
suite.Suite
TestUserId string
AliasRepository *mocks.AliasRepositoryMock
}
func (suite *AliasServiceTestSuite) SetupSuite() {
suite.TestUserId = "johndoe@example.org"
aliases := []*models.Alias{
{
Type: models.SummaryProject,
UserID: suite.TestUserId,
Key: "wakapi",
Value: "wakapi-mobile",
},
}
aliasRepoMock := new(mocks.AliasRepositoryMock)
aliasRepoMock.On("GetByUser", suite.TestUserId).Return(aliases, nil)
aliasRepoMock.On("GetByUser", mock.AnythingOfType("string")).Return([]*models.Alias{}, assert.AnError)
suite.AliasRepository = aliasRepoMock
}
func TestAliasServiceTestSuite(t *testing.T) {
suite.Run(t, new(AliasServiceTestSuite))
}
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
sut := NewAliasService(suite.AliasRepository)
result1, err1 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi-mobile")
result2, err2 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi")
result3, err3 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "anchr")
assert.Equal(suite.T(), "wakapi", result1)
assert.Nil(suite.T(), err1)
assert.Equal(suite.T(), "wakapi", result2)
assert.Nil(suite.T(), err2)
assert.Equal(suite.T(), "anchr", result3)
assert.Nil(suite.T(), err3)
}
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault_ErrorOnNonExistingUser() {
sut := NewAliasService(suite.AliasRepository)
result, err := sut.GetAliasOrDefault("nonexisting", models.SummaryProject, "wakapi-mobile")
assert.Empty(suite.T(), result)
assert.Error(suite.T(), err)
}

View File

@ -8,17 +8,13 @@ import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
) )
const (
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
)
type HeartbeatService struct { type HeartbeatService struct {
config *config.Config config *config.Config
repository *repositories.HeartbeatRepository repository repositories.IHeartbeatRepository
languageMappingSrvc *LanguageMappingService languageMappingSrvc ILanguageMappingService
} }
func NewHeartbeatService(heartbeatRepo *repositories.HeartbeatRepository, languageMappingService *LanguageMappingService) *HeartbeatService { func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
return &HeartbeatService{ return &HeartbeatService{
config: config.Get(), config: config.Get(),
repository: heartbeatRepo, repository: heartbeatRepo,
@ -38,8 +34,8 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
return srv.augmented(heartbeats, user.ID) return srv.augmented(heartbeats, user.ID)
} }
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) { func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
return srv.repository.GetFirstByUsers(userIds) return srv.repository.GetFirstByUsers()
} }
func (srv *HeartbeatService) DeleteBefore(t time.Time) error { func (srv *HeartbeatService) DeleteBefore(t time.Time) error {

View File

@ -8,10 +8,10 @@ import (
type KeyValueService struct { type KeyValueService struct {
config *config.Config config *config.Config
repository *repositories.KeyValueRepository repository repositories.IKeyValueRepository
} }
func NewKeyValueService(keyValueRepo *repositories.KeyValueRepository) *KeyValueService { func NewKeyValueService(keyValueRepo repositories.IKeyValueRepository) *KeyValueService {
return &KeyValueService{ return &KeyValueService{
config: config.Get(), config: config.Get(),
repository: keyValueRepo, repository: keyValueRepo,

View File

@ -10,11 +10,11 @@ import (
type LanguageMappingService struct { type LanguageMappingService struct {
config *config.Config config *config.Config
repository *repositories.LanguageMappingRepository
cache *cache.Cache cache *cache.Cache
repository repositories.ILanguageMappingRepository
} }
func NewLanguageMappingService(languageMappingsRepo *repositories.LanguageMappingRepository) *LanguageMappingService { func NewLanguageMappingService(languageMappingsRepo repositories.ILanguageMappingRepository) *LanguageMappingService {
return &LanguageMappingService{ return &LanguageMappingService{
config: config.Get(), config: config.Get(),
repository: languageMappingsRepo, repository: languageMappingsRepo,
@ -68,6 +68,7 @@ func (srv *LanguageMappingService) Delete(mapping *models.LanguageMapping) error
return err return err
} }
func (srv LanguageMappingService) getServerMappings() map[string]string { func (srv *LanguageMappingService) getServerMappings() map[string]string {
return srv.config.App.CustomLanguages // https://dave.cheney.net/2017/04/30/if-a-map-isnt-a-reference-variable-what-is-it
return srv.config.App.GetCustomLanguages()
} }

58
services/services.go Normal file
View File

@ -0,0 +1,58 @@
package services
import (
"github.com/muety/wakapi/models"
"time"
)
type IAggregationService interface {
Schedule()
Run(map[string]bool) error
}
type IAliasService interface {
LoadUserAliases(string) error
GetAliasOrDefault(string, uint8, string) (string, error)
IsInitialized(string) bool
}
type IHeartbeatService interface {
InsertBatch([]*models.Heartbeat) error
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
DeleteBefore(time.Time) error
}
type IKeyValueService interface {
GetString(string) (*models.KeyStringValue, error)
PutString(*models.KeyStringValue) error
DeleteString(string) error
}
type ILanguageMappingService interface {
GetById(uint) (*models.LanguageMapping, error)
GetByUser(string) ([]*models.LanguageMapping, error)
ResolveByUser(string) (map[string]string, error)
Create(*models.LanguageMapping) (*models.LanguageMapping, error)
Delete(mapping *models.LanguageMapping) error
}
type ISummaryService interface {
Aliased(time.Time, time.Time, *models.User, SummaryRetriever) (*models.Summary, error)
Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error)
Summarize(time.Time, time.Time, *models.User) (*models.Summary, error)
GetLatestByUser() ([]*models.TimeByUser, error)
DeleteByUser(string) error
Insert(*models.Summary) error
}
type IUserService interface {
GetUserById(string) (*models.User, error)
GetUserByKey(string) (*models.User, error)
GetAll() ([]*models.User, error)
CreateOrGet(*models.Signup) (*models.User, bool, error)
Update(*models.User) (*models.User, error)
ResetApiKey(*models.User) (*models.User, error)
ToggleBadges(*models.User) (*models.User, error)
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
}

View File

@ -4,14 +4,12 @@ import (
"crypto/md5" "crypto/md5"
"errors" "errors"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories" "github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"math" "math"
"sort" "sort"
"strconv"
"time" "time"
"github.com/muety/wakapi/models"
) )
const HeartbeatDiffThreshold = 2 * time.Minute const HeartbeatDiffThreshold = 2 * time.Minute
@ -19,12 +17,14 @@ const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct { type SummaryService struct {
config *config.Config config *config.Config
cache *cache.Cache cache *cache.Cache
repository *repositories.SummaryRepository repository repositories.ISummaryRepository
heartbeatService *HeartbeatService heartbeatService IHeartbeatService
aliasService *AliasService aliasService IAliasService
} }
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService { type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
return &SummaryService{ return &SummaryService{
config: config.Get(), config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour), cache: cache.New(24*time.Hour, 24*time.Hour),
@ -34,60 +34,98 @@ func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatSer
} }
} }
type Interval struct { // Public summary generation methods
Start time.Time
End time.Time
}
// TODO: simplify! func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever) (*models.Summary, error) {
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) { // Check cache
var existingSummaries []*models.Summary cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
var cacheKey string if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(*models.Summary), nil
if recompute {
existingSummaries = make([]*models.Summary, 0)
} else {
cacheKey = getHash([]time.Time{from, to}, user)
if result, ok := srv.cache.Get(cacheKey); ok {
return result.(*models.Summary), nil
}
summaries, err := srv.GetByUserWithin(user, from, to)
if err != nil {
return nil, err
}
existingSummaries = summaries
} }
missingIntervals := getMissingIntervals(from, to, existingSummaries) // Wrap alias resolution
resolve := func(t uint8, k string) string {
s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k)
return s
}
heartbeats := make([]*models.Heartbeat, 0) // Initialize alias resolver service
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
return nil, err
}
// Get actual summary
s, err := f(from, to, user)
if err != nil {
return nil, err
}
// Post-process summary and cache it
summary := s.WithResolvedAliases(resolve)
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(*models.Summary), nil
}
// Get all already existing, pre-generated summaries that fall into the requested interval
summaries, err := srv.repository.GetByUserWithin(user, from, to)
if err != nil {
return nil, err
}
// Generate missing slots (especially before and after existing summaries) from raw heartbeats
missingIntervals := srv.getMissingIntervals(from, to, summaries)
for _, interval := range missingIntervals { for _, interval := range missingIntervals {
hb, err := srv.heartbeatService.GetAllWithin(interval.Start, interval.End, user) if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
if err != nil { summaries = append(summaries, s)
} else {
return nil, err return nil, err
} }
heartbeats = append(heartbeats, hb...) }
// Merge existing and newly generated summary snippets
summary, err := srv.mergeSummaries(summaries)
if err != nil {
return nil, err
}
// Cache 'em
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
// Initialize and fetch data
var heartbeats models.Heartbeats
if rawHeartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user); err == nil {
heartbeats = rawHeartbeats
} else {
return nil, err
} }
types := models.SummaryTypes() types := models.SummaryTypes()
typedAggregations := make(chan models.SummaryItemContainer)
defer close(typedAggregations)
for _, t := range types {
go srv.aggregateBy(heartbeats, t, typedAggregations)
}
// Aggregate raw heartbeats by types in parallel and collect them
var projectItems []*models.SummaryItem var projectItems []*models.SummaryItem
var languageItems []*models.SummaryItem var languageItems []*models.SummaryItem
var editorItems []*models.SummaryItem var editorItems []*models.SummaryItem
var osItems []*models.SummaryItem var osItems []*models.SummaryItem
var machineItems []*models.SummaryItem var machineItems []*models.SummaryItem
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
return nil, err
}
c := make(chan models.SummaryItemContainer)
for _, t := range types {
go srv.aggregateBy(heartbeats, t, user, c)
}
for i := 0; i < len(types); i++ { for i := 0; i < len(types); i++ {
item := <-c item := <-typedAggregations
switch item.Type { switch item.Type {
case models.SummaryProject: case models.SummaryProject:
projectItems = item.Items projectItems = item.Items
@ -101,31 +139,16 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
machineItems = item.Items machineItems = item.Items
} }
} }
close(c)
realFrom, realTo := from, to if heartbeats.Len() > 0 {
if len(existingSummaries) > 0 { from = time.Time(heartbeats.First().Time)
realFrom = existingSummaries[0].FromTime.T() to = time.Time(heartbeats.Last().Time)
realTo = existingSummaries[len(existingSummaries)-1].ToTime.T()
for _, summary := range existingSummaries {
summary.FillUnknown()
}
}
if len(heartbeats) > 0 {
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
realFrom = t1
}
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
realTo = t2
}
} }
aggregatedSummary := &models.Summary{ summary := &models.Summary{
UserID: user.ID, UserID: user.ID,
FromTime: models.CustomTime(realFrom), FromTime: models.CustomTime(from),
ToTime: models.CustomTime(realTo), ToTime: models.CustomTime(to),
Projects: projectItems, Projects: projectItems,
Languages: languageItems, Languages: languageItems,
Editors: editorItems, Editors: editorItems,
@ -133,123 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
Machines: machineItems, Machines: machineItems,
} }
allSummaries := []*models.Summary{aggregatedSummary} //summary.FillUnknown()
allSummaries = append(allSummaries, existingSummaries...)
summary, err := mergeSummaries(allSummaries) return summary.Sorted(), nil
if err != nil {
return nil, err
}
if cacheKey != "" {
srv.cache.SetDefault(cacheKey, summary)
}
return summary, nil
} }
func (srv *SummaryService) PostProcessWrapped(summary *models.Summary, err error) (*models.Summary, error) { // CRUD methods
if err != nil {
return nil, err
}
return srv.PostProcess(summary), nil
}
func (srv *SummaryService) PostProcess(summary *models.Summary) *models.Summary { func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
updatedSummary := &models.Summary{ return srv.repository.GetLastByUser()
ID: summary.ID,
UserID: summary.UserID,
FromTime: summary.FromTime,
ToTime: summary.ToTime,
}
processAliases := func(origin []*models.SummaryItem) []*models.SummaryItem {
target := make([]*models.SummaryItem, 0)
findItem := func(key string) *models.SummaryItem {
for _, item := range target {
if item.Key == key {
return item
}
}
return nil
}
for _, item := range origin {
// Add all "top-level" items, i.e. such without aliases
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key == item.Key {
target = append(target, item)
}
}
for _, item := range origin {
// Add all remaining projects and merge with their alias
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key != item.Key {
if targetItem := findItem(key); targetItem != nil {
targetItem.Total += item.Total
} else {
target = append(target, &models.SummaryItem{
ID: item.ID,
SummaryID: item.SummaryID,
Type: item.Type,
Key: key,
Total: item.Total,
})
}
}
}
return target
}
// Resolve aliases
updatedSummary.Projects = processAliases(summary.Projects)
updatedSummary.Editors = processAliases(summary.Editors)
updatedSummary.Languages = processAliases(summary.Languages)
updatedSummary.OperatingSystems = processAliases(summary.OperatingSystems)
updatedSummary.Machines = processAliases(summary.Machines)
return updatedSummary
}
func (srv *SummaryService) Insert(summary *models.Summary) error {
return srv.repository.Insert(summary)
}
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
return srv.repository.GetByUserWithin(user, from, to)
}
// Will return *models.Index objects with only user_id and to_time filled
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
return srv.repository.GetLatestByUser()
} }
func (srv *SummaryService) DeleteByUser(userId string) error { func (srv *SummaryService) DeleteByUser(userId string) error {
return srv.repository.DeleteByUser(userId) return srv.repository.DeleteByUser(userId)
} }
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, user *models.User, c chan models.SummaryItemContainer) { func (srv *SummaryService) Insert(summary *models.Summary) error {
return srv.repository.Insert(summary)
}
// Private summary generation and utility methods
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) {
durations := make(map[string]time.Duration) durations := make(map[string]time.Duration)
for i, h := range heartbeats { for i, h := range heartbeats {
var key string key := h.GetKey(summaryType)
switch summaryType {
case models.SummaryProject:
key = h.Project
case models.SummaryEditor:
key = h.Editor
case models.SummaryLanguage:
key = h.Language
case models.SummaryOS:
key = h.OperatingSystem
case models.SummaryMachine:
key = h.Machine
}
if key == "" {
key = models.UnknownSummaryKey
}
if _, ok := durations[key]; !ok { if _, ok := durations[key]; !ok {
durations[key] = time.Duration(0) durations[key] = time.Duration(0)
@ -287,43 +219,7 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
c <- models.SummaryItemContainer{Type: summaryType, Items: items} c <- models.SummaryItemContainer{Type: summaryType, Items: items}
} }
func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary) []*Interval { func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
if len(existingSummaries) == 0 {
return []*Interval{{from, to}}
}
intervals := make([]*Interval, 0)
// Pre
if from.Before(existingSummaries[0].FromTime.T()) {
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime.T()})
}
// Between
for i := 0; i < len(existingSummaries)-1; i++ {
t1, t2 := existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()
if t1.Equal(t2) {
continue
}
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &Interval{existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()})
}
}
// Post
if to.After(existingSummaries[len(existingSummaries)-1].ToTime.T()) {
intervals = append(intervals, &Interval{existingSummaries[len(existingSummaries)-1].ToTime.T(), to})
}
return intervals
}
func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
if len(summaries) < 1 { if len(summaries) < 1 {
return nil, errors.New("no summaries given") return nil, errors.New("no summaries given")
} }
@ -353,11 +249,11 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
maxTime = s.ToTime.T() maxTime = s.ToTime.T()
} }
finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects) finalSummary.Projects = srv.mergeSummaryItems(finalSummary.Projects, s.Projects)
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages) finalSummary.Languages = srv.mergeSummaryItems(finalSummary.Languages, s.Languages)
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors) finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems) finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines) finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
} }
finalSummary.FromTime = models.CustomTime(minTime) finalSummary.FromTime = models.CustomTime(minTime)
@ -366,7 +262,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
return finalSummary, nil return finalSummary, nil
} }
func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem { func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
items := make(map[string]*models.SummaryItem) items := make(map[string]*models.SummaryItem)
// Build map from existing // Build map from existing
@ -396,11 +292,46 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem
return itemList return itemList
} }
func getHash(times []time.Time, user *models.User) string { func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
digest := md5.New() if len(summaries) == 0 {
for _, t := range times { return []*models.Interval{{from, to}}
digest.Write([]byte(strconv.Itoa(int(t.Unix())))) }
intervals := make([]*models.Interval, 0)
// Pre
if from.Before(summaries[0].FromTime.T()) {
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
}
// Between
for i := 0; i < len(summaries)-1; i++ {
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
if t1.Equal(t2) {
continue
}
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
}
}
// Post
if to.After(summaries[len(summaries)-1].ToTime.T()) {
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
}
return intervals
}
func (srv *SummaryService) getHash(args ...string) string {
digest := md5.New()
for _, a := range args {
digest.Write([]byte(a))
} }
digest.Write([]byte(user.ID))
return string(digest.Sum(nil)) return string(digest.Sum(nil))
} }

290
services/summary_test.go Normal file
View File

@ -0,0 +1,290 @@
package services
import (
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"math/rand"
"strings"
"testing"
"time"
)
const (
TestUserId = "muety"
TestProject1 = "test-project-1"
TestProject2 = "test-project-2"
TestLanguageGo = "Go"
TestLanguageJava = "Java"
TestLanguagePython = "Python"
TestEditorGoland = "GoLand"
TestEditorIntellij = "idea"
TestEditorVscode = "vscode"
TestOsLinux = "Linux"
TestOsWin = "Windows"
TestMachine1 = "muety-desktop"
TestMachine2 = "muety-work"
MinUnixTime1 = 1601510400000 * 1e6
)
type SummaryServiceTestSuite struct {
suite.Suite
TestUser *models.User
TestStartTime time.Time
TestHeartbeats []*models.Heartbeat
SummaryRepository *mocks.SummaryRepositoryMock
HeartbeatService *mocks.HeartbeatServiceMock
AliasService *mocks.AliasServiceMock
}
func (suite *SummaryServiceTestSuite) SetupSuite() {
suite.TestUser = &models.User{ID: TestUserId}
suite.TestStartTime = time.Unix(0, MinUnixTime1)
suite.TestHeartbeats = []*models.Heartbeat{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime),
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)),
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorVscode,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
},
}
}
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
suite.AliasService = new(mocks.AliasServiceMock)
}
func TestSummaryServiceTestSuite(t *testing.T) {
suite.Run(t, new(SummaryServiceTestSuite))
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
from time.Time
to time.Time
result *models.Summary
err error
)
/* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), from, result.FromTime.T())
assert.Equal(suite.T(), to, result.ToTime.T())
assert.Zero(suite.T(), result.TotalTime())
assert.Empty(suite.T(), result.Projects)
/* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.ToTime.T())
assert.Zero(suite.T(), result.TotalTime())
assertNumAllItems(suite.T(), 1, result, "")
/* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
assert.Equal(suite.T(), suite.TestHeartbeats[len(suite.TestHeartbeats)-1].Time.T(), result.ToTime.T())
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
assert.Len(suite.T(), result.Editors, 2)
assertNumAllItems(suite.T(), 1, result, "e")
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
summaries []*models.Summary
from time.Time
to time.Time
result *models.Summary
err error
)
/* TEST 1 */
from, to = suite.TestStartTime.Add(-12*time.Hour), suite.TestStartTime.Add(12*time.Hour)
summaries = []*models.Summary{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(from.Add(10 * time.Minute)),
ToTime: models.CustomTime(to.Add(-10 * time.Minute)),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject1,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
}
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil)
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 1)
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2)
/* TEST 2 */
from, to = suite.TestStartTime.Add(-10*time.Minute), suite.TestStartTime.Add(12*time.Hour)
summaries = []*models.Summary{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(from.Add(20 * time.Minute)),
ToTime: models.CustomTime(to.Add(-6 * time.Hour)),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject1,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
FromTime: models.CustomTime(to.Add(-6 * time.Hour)),
ToTime: models.CustomTime(to),
Projects: []*models.SummaryItem{
{
Type: models.SummaryProject,
Key: TestProject2,
Total: 45 * time.Minute / time.Second, // hack
},
},
Languages: []*models.SummaryItem{},
Editors: []*models.SummaryItem{},
OperatingSystems: []*models.SummaryItem{},
Machines: []*models.SummaryItem{},
},
}
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return(filter(from, summaries[0].FromTime.T(), suite.TestHeartbeats), nil)
result, err = sut.Retrieve(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 2)
assert.Equal(suite.T(), 150*time.Second+90*time.Minute, result.TotalTime())
assert.Equal(suite.T(), 150*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
var (
from time.Time
to time.Time
result *models.Summary
err error
)
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.AliasService.On("LoadUserAliases", TestUserId).Return(nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, models.SummaryProject, TestProject1).Return(TestProject2, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject1))
assert.NotZero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject2))
}
func filter(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
for _, h := range heartbeats {
if (h.Time.T().Equal(from) || h.Time.T().After(from)) && h.Time.T().Before(to) {
filtered = append(filtered, h)
}
}
return filtered
}
func assertNumAllItems(t *testing.T, expected int, summary *models.Summary, except string) {
if !strings.Contains(except, "p") {
assert.Len(t, summary.Projects, expected)
}
if !strings.Contains(except, "e") {
assert.Len(t, summary.Editors, expected)
}
if !strings.Contains(except, "l") {
assert.Len(t, summary.Languages, expected)
}
if !strings.Contains(except, "o") {
assert.Len(t, summary.OperatingSystems, expected)
}
if !strings.Contains(except, "m") {
assert.Len(t, summary.Machines, expected)
}
}

View File

@ -10,10 +10,10 @@ import (
type UserService struct { type UserService struct {
Config *config.Config Config *config.Config
repository *repositories.UserRepository repository repositories.IUserRepository
} }
func NewUserService(userRepo *repositories.UserRepository) *UserService { func NewUserService(userRepo repositories.IUserRepository) *UserService {
return &UserService{ return &UserService{
Config: config.Get(), Config: config.Get(),
repository: userRepo, repository: userRepo,

3
sonar-project.properties Normal file
View File

@ -0,0 +1,3 @@
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**
sonar.tests=.
sonar.go.coverage.reportPaths=coverage/coverage.out

View File

@ -1,4 +1,3 @@
const SHOW_TOP_N = 10
const CHART_TARGET_SIZE = 200 const CHART_TARGET_SIZE = 200
const projectsCanvas = document.getElementById('chart-projects') const projectsCanvas = document.getElementById('chart-projects')
@ -17,7 +16,16 @@ const containers = [projectContainer, osContainer, editorContainer, languageCont
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas] const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines] const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
topNPickers.forEach(e => {
const idx = parseInt(e.attributes['data-entity'].value)
e.max = Math.min(data[idx].length, 10)
e.value = e.max
})
let charts = [] let charts = []
let showTopN = []
let resizeCount = 0 let resizeCount = 0
String.prototype.toHHMMSS = function () { String.prototype.toHHMMSS = function () {
@ -38,7 +46,7 @@ String.prototype.toHHMMSS = function () {
return hours + ':' + minutes + ':' + seconds return hours + ':' + minutes + ':' + seconds
} }
function draw() { function draw(subselection) {
function getTooltipOptions(key, type) { function getTooltipOptions(key, type) {
return { return {
mode: 'single', mode: 'single',
@ -47,19 +55,26 @@ function draw() {
let idx = type === 'pie' ? item.index : item.datasetIndex let idx = type === 'pie' ? item.index : item.datasetIndex
let d = wakapiData[key][idx] let d = wakapiData[key][idx]
return `${d.key}: ${d.total.toString().toHHMMSS()}` return `${d.key}: ${d.total.toString().toHHMMSS()}`
} },
title: () => 'Total Time'
} }
} }
} }
charts.forEach(c => c.destroy()) function shouldUpdate(index) {
return !subselection || (subselection.includes(index) && data[index].length >= showTopN[index])
}
let projectChart = !projectsCanvas.classList.contains('hidden') charts
.filter((c, i) => shouldUpdate(i))
.forEach(c => c.destroy())
let projectChart = !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
? new Chart(projectsCanvas.getContext('2d'), { ? new Chart(projectsCanvas.getContext('2d'), {
type: 'horizontalBar', type: 'horizontalBar',
data: { data: {
datasets: wakapiData.projects datasets: wakapiData.projects
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length)) .slice(0, Math.min(showTopN[0], wakapiData.projects.length))
.map(p => { .map(p => {
return { return {
label: p.key, label: p.key,
@ -87,18 +102,18 @@ function draw() {
}) })
: null : null
let osChart = !osCanvas.classList.contains('hidden') let osChart = !osCanvas.classList.contains('hidden') && shouldUpdate(1)
? new Chart(osCanvas.getContext('2d'), { ? new Chart(osCanvas.getContext('2d'), {
type: 'pie', type: 'pie',
data: { data: {
datasets: [{ datasets: [{
data: wakapiData.operatingSystems data: wakapiData.operatingSystems
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length)) .slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => parseInt(p.total)), .map(p => parseInt(p.total)),
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key)) backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
}], }],
labels: wakapiData.operatingSystems labels: wakapiData.operatingSystems
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length)) .slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => p.key) .map(p => p.key)
}, },
options: { options: {
@ -109,18 +124,18 @@ function draw() {
}) })
: null : null
let editorChart = !editorsCanvas.classList.contains('hidden') let editorChart = !editorsCanvas.classList.contains('hidden') && shouldUpdate(2)
? new Chart(editorsCanvas.getContext('2d'), { ? new Chart(editorsCanvas.getContext('2d'), {
type: 'pie', type: 'pie',
data: { data: {
datasets: [{ datasets: [{
data: wakapiData.editors data: wakapiData.editors
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length)) .slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => parseInt(p.total)), .map(p => parseInt(p.total)),
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key)) backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
}], }],
labels: wakapiData.editors labels: wakapiData.editors
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length)) .slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => p.key) .map(p => p.key)
}, },
options: { options: {
@ -131,18 +146,18 @@ function draw() {
}) })
: null : null
let languageChart = !languagesCanvas.classList.contains('hidden') let languageChart = !languagesCanvas.classList.contains('hidden') && shouldUpdate(3)
? new Chart(languagesCanvas.getContext('2d'), { ? new Chart(languagesCanvas.getContext('2d'), {
type: 'pie', type: 'pie',
data: { data: {
datasets: [{ datasets: [{
data: wakapiData.languages data: wakapiData.languages
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length)) .slice(0, Math.min(showTopN[3], wakapiData.languages.length))
.map(p => parseInt(p.total)), .map(p => parseInt(p.total)),
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key)) backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
}], }],
labels: wakapiData.languages labels: wakapiData.languages
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length)) .slice(0, Math.min(showTopN[3], wakapiData.languages.length))
.map(p => p.key) .map(p => p.key)
}, },
options: { options: {
@ -153,18 +168,18 @@ function draw() {
}) })
: null : null
let machineChart = !machinesCanvas.classList.contains('hidden') let machineChart = !machinesCanvas.classList.contains('hidden') && shouldUpdate(4)
? new Chart(machinesCanvas.getContext('2d'), { ? new Chart(machinesCanvas.getContext('2d'), {
type: 'pie', type: 'pie',
data: { data: {
datasets: [{ datasets: [{
data: wakapiData.machines data: wakapiData.machines
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length)) .slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => parseInt(p.total)), .map(p => parseInt(p.total)),
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key)) backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key))
}], }],
labels: wakapiData.machines labels: wakapiData.machines
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length)) .slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => p.key) .map(p => p.key)
}, },
options: { options: {
@ -179,13 +194,14 @@ function draw() {
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c) charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
charts.forEach(c => c.options.onResize(c.chart)) if (!subselection) {
equalizeHeights() charts.forEach(c => c.options.onResize(c.chart))
equalizeHeights()
}
} }
function setTopLabels() { function parseTopN() {
[...document.getElementsByClassName('top-label')] showTopN = topNPickers.map(e => parseInt(e.value))
.forEach(e => e.innerText = `(top ${SHOW_TOP_N})`)
} }
function togglePlaceholders(mask) { function togglePlaceholders(mask) {
@ -203,7 +219,7 @@ function togglePlaceholders(mask) {
} }
function getPresentDataMask() { function getPresentDataMask() {
return data.map(list => list ? list.reduce((acc, e) => acc + e.total, 0) : 0 > 0) return data.map(list => (list ? list.reduce((acc, e) => acc + e.total, 0) : 0) > 0)
} }
function getContainer(chart) { function getContainer(chart) {
@ -300,7 +316,12 @@ window.addEventListener('click', function (event) {
}) })
window.addEventListener('load', function () { window.addEventListener('load', function () {
setTopLabels() topNPickers.forEach(e => e.addEventListener('change', () => {
parseTopN()
draw([parseInt(e.attributes['data-entity'].value)])
}))
parseTopN()
togglePlaceholders(getPresentDataMask()) togglePlaceholders(getPresentDataMask())
draw() draw()
}) })

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill="#eee"><path fill-rule="evenodd" clip-rule="evenodd" d="M64 5.103c-33.347 0-60.388 27.035-60.388 60.388 0 26.682 17.303 49.317 41.297 57.303 3.017.56 4.125-1.31 4.125-2.905 0-1.44-.056-6.197-.082-11.243-16.8 3.653-20.345-7.125-20.345-7.125-2.747-6.98-6.705-8.836-6.705-8.836-5.48-3.748.413-3.67.413-3.67 6.063.425 9.257 6.223 9.257 6.223 5.386 9.23 14.127 6.562 17.573 5.02.542-3.903 2.107-6.568 3.834-8.076-13.413-1.525-27.514-6.704-27.514-29.843 0-6.593 2.36-11.98 6.223-16.21-.628-1.52-2.695-7.662.584-15.98 0 0 5.07-1.623 16.61 6.19C53.7 35 58.867 34.327 64 34.304c5.13.023 10.3.694 15.127 2.033 11.526-7.813 16.59-6.19 16.59-6.19 3.287 8.317 1.22 14.46.593 15.98 3.872 4.23 6.215 9.617 6.215 16.21 0 23.194-14.127 28.3-27.574 29.796 2.167 1.874 4.097 5.55 4.097 11.183 0 8.08-.07 14.583-.07 16.572 0 1.607 1.088 3.49 4.148 2.897 23.98-7.994 41.263-30.622 41.263-57.294C124.388 32.14 97.35 5.104 64 5.104z"/><path d="M26.484 91.806c-.133.3-.605.39-1.035.185-.44-.196-.685-.605-.543-.906.13-.31.603-.395 1.04-.188.44.197.69.61.537.91zm-.743-.55M28.93 94.535c-.287.267-.85.143-1.232-.28-.396-.42-.47-.983-.177-1.254.298-.266.844-.14 1.24.28.394.426.472.984.17 1.255zm-.575-.618M31.312 98.012c-.37.258-.976.017-1.35-.52-.37-.538-.37-1.183.01-1.44.373-.258.97-.025 1.35.507.368.545.368 1.19-.01 1.452zm0 0M34.573 101.373c-.33.365-1.036.267-1.552-.23-.527-.487-.674-1.18-.343-1.544.336-.366 1.045-.264 1.564.23.527.486.686 1.18.333 1.543zm0 0M39.073 103.324c-.147.473-.825.688-1.51.486-.683-.207-1.13-.76-.99-1.238.14-.477.823-.7 1.512-.485.683.206 1.13.756.988 1.237zm0 0M44.016 103.685c.017.498-.563.91-1.28.92-.723.017-1.308-.387-1.315-.877 0-.503.568-.91 1.29-.924.717-.013 1.306.387 1.306.88zm0 0M48.614 102.903c.086.485-.413.984-1.126 1.117-.7.13-1.35-.172-1.44-.653-.086-.498.422-.997 1.122-1.126.714-.123 1.354.17 1.444.663zm0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "assets/images/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/images/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -2,10 +2,11 @@ package utils
import ( import (
"errors" "errors"
"github.com/stretchr/testify/assert"
"testing" "testing"
) )
func TestParseUserAgent(t *testing.T) { func TestCommon_ParseUserAgent(t *testing.T) {
tests := []struct { tests := []struct {
in string in string
outOs string outOs string
@ -38,10 +39,11 @@ func TestParseUserAgent(t *testing.T) {
}, },
} }
for i, test := range tests { for _, test := range tests {
if os, editor, err := ParseUserAgent(test.in); os != test.outOs || editor != test.outEditor || !checkErr(test.outError, err) { os, editor, err := ParseUserAgent(test.in)
t.Errorf("[%d] Unexpected result of parsing '%s'; got '%v', '%v', '%v'", i, test.in, os, editor, err) assert.True(t, checkErr(err, test.outError))
} assert.Equal(t, test.outOs, os)
assert.Equal(t, test.outEditor, editor)
} }
} }

View File

@ -13,13 +13,3 @@ func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
log.Printf("error while writing json response: %v", err) log.Printf("error while writing json response: %v", err)
} }
} }
func ClearCookie(w http.ResponseWriter, name string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
Secure: secure,
HttpOnly: true,
})
}

View File

@ -1 +1 @@
1.15.1 1.18.1

View File

@ -1,14 +1,2 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.bundle.min.js"></script>
<script>
const languageColors = {{ .LanguageColors | json }}
let wakapiData = {}
wakapiData.projects = {{ .Projects | json }}
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
wakapiData.machines = {{ .Machines | json }}
</script>
<script src="assets/app.js"></script>

View File

@ -2,7 +2,10 @@
<title>Wakapi Coding Statistics</title> <title>Wakapi Coding Statistics</title>
<base href="{{ getBasePath }}/"> <base href="{{ getBasePath }}/">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
<link rel="icon" data-emoji="📊" type="image/png"> <link rel="apple-touch-icon" sizes="180x180" href="assets/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="assets/images/favicon-16x16.png">
<link rel="manifest" href="assets/site.webmanifest">
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
<link href="https://unpkg.com/tailwindcss@^1.4.6/dist/tailwind.min.css" rel="stylesheet"> <link href="https://unpkg.com/tailwindcss@^1.4.6/dist/tailwind.min.css" rel="stylesheet">
<link href="assets/app.css" rel="stylesheet"> <link href="assets/app.css" rel="stylesheet">

6
views/header.tpl.html Normal file
View File

@ -0,0 +1,6 @@
<header class="flex justify-between mb-10">
<a id="logo-container" class="text-2xl font-semibold text-white inline-block" href="">
<span>&#x1f4ca;</span>
<span>Wakapi</span>
</a>
</header>

View File

@ -4,6 +4,9 @@
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center"> <body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow"> <div class="flex items-center justify-between max-w-4xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div> <div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div>

View File

@ -3,35 +3,62 @@
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center"> <body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
<div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Login</h1> {{ template "header.tpl.html" . }}
</div>
{{ template "alerts.tpl.html" . }} {{ template "alerts.tpl.html" . }}
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
<div class="mx-1">
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">🔑 &nbsp;Login</a>
</div>
</div>
<main class="mt-10 flex-grow flex justify-center w-full"> <main class="mt-10 flex-grow flex justify-center w-full">
<div class="flex-grow max-w-lg mt-12"> <div class="flex flex-col text-white">
<form action="login" method="post"> <h1 class="text-4xl font-semibold antialiased text-center mb-2">Keep Track of <span class="text-green-700">Your</span> Coding Time 🕓</h1>
<div class="mb-8"> <p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the time you have spent coding on different projects in different programming languages and more. Ideal for statistics freaks any anyone else.</p>
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" <div class="flex justify-center mt-4 mb-8 space-x-2">
type="text" id="username" <a href="login">
name="username" placeholder="Enter your username" minlength="3" required autofocus> <button type="button" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold">🚀 Try it!</button>
</a>
<a href="https://github.com/muety/wakapi#%EF%B8%8F-server-setup" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">📡 Host it</button>
</a>
<a href="https://github.com/muety/wakapi" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">
<img alt="GitHub Icon" src="assets/images/ghicon.svg" width="22px">
</button>
</a>
</div>
<div class="flex justify-center my-8">
<img alt="App screenshot" src="assets/images/screenshot.png">
</div>
<div class="flex flex-col items-center mt-10">
<h1 class="font-semibold text-xl text-white m-0 border-b-4 border-green-700">Features</h1>
<div class="mt-4 text-lg">
<ul>
<li>&nbsp; 100 % free and open-source</li>
<li>&nbsp; Built by developers for developers</li>
<li>&nbsp; Fancy statistics and plots</li>
<li>&nbsp; Cool badges for readmes</li>
<li>&nbsp; Intuitive REST API</li>
<li>&nbsp; Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
<li>&nbsp; <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics via <a href="https://github.com/MacroPower/wakatime_exporter" target="_blank" rel="noopener noreferrer" class="underline">exporter</a></li>
<li>&nbsp; Self-hosted</li>
</ul>
</div> </div>
<div class="mb-8"> </div>
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" <div class="flex justify-center space-x-2 mt-12">
type="password" id="password" <img alt="License badge" src="https://badges.fw-web.space/github/license/muety/wakapi?color=%232F855A&style=flat-square">
name="password" placeholder="******" minlength="6" required> <img alt="Go version badge" src="https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi?color=%232F855A&style=flat-square">
</div> <img alt="Wakapi coding time badge" src="https://badges.fw-web.space/endpoint?color=%232F855A&style=flat-square&label=wakapi&url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi">
<div class="flex justify-between"> </div>
<a href="signup">
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
</a>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
</div>
</form>
</div> </div>
</main> </main>

46
views/login.tpl.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="flex items-center justify-center">
<h1 class="font-semibold text-xl text-white m-0 border-b-4 border-green-700">Login</h1>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow flex justify-center w-full">
<div class="flex-grow max-w-lg mt-10">
<form action="login" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="text" id="username"
name="username" placeholder="Enter your username" minlength="3" required autofocus>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password"
name="password" placeholder="******" minlength="6" required>
</div>
<div class="flex justify-between">
<a href="signup">
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
</a>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
</div>
</form>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>

View File

@ -4,18 +4,21 @@
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center"> <body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow"> <div class="flex items-center justify-between max-w-xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div> <div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div> <div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div>
<div></div> <div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</div>
</div> </div>
</div> </div>
{{ template "alerts.tpl.html" . }} {{ template "alerts.tpl.html" . }}
<main class="mt-4 flex-grow flex justify-center w-full"> <main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-lg mt-8"> <div class="flex flex-col flex-grow max-w-xl mt-8">
<div class="w-full my-8 pb-8 border-b border-gray-700"> <div class="w-full my-8 pb-8 border-b border-gray-700">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"> <div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Change Password Change Password
@ -72,7 +75,7 @@
</div> </div>
<div class="text-gray-300 text-sm mb-4 mt-6"> <div class="text-gray-300 text-sm mb-4 mt-6">
You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>. You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>.)
</div> </div>
{{ if .LanguageMappings }} {{ if .LanguageMappings }}

View File

@ -5,6 +5,8 @@
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center"> <body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup" <div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup"
id="api-key-popup"> id="api-key-popup">
<div class="flex-grow flex flex-col px-2"> <div class="flex-grow flex flex-col px-2">
@ -64,9 +66,13 @@
<div class="flex flex-wrap justify-center"> <div class="flex flex-wrap justify-center">
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="project-container" style="height: 300px"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="project-container" style="height: 300px">
<div class="self-center flex"> <div class="flex justify-between">
<span class="font-semibold mr-1">Projects</span> <div class="w-1/4 flex-1"></div>
<span id="project-top-label" class="top-label"></span> <span class="font-semibold w-1/2 text-center flex-1">Projects</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="project-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="project-top-picker" data-entity="0" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div> </div>
<canvas id="chart-projects"></canvas> <canvas id="chart-projects"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -77,9 +83,13 @@
</div> </div>
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="os-container" style="height: 300px"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="os-container" style="height: 300px">
<div class="self-center flex"> <div class="flex justify-between">
<span class="font-semibold mr-1">Operating Systems</span> <div class="w-1/4 flex-1"></div>
<span id="os-top-label" class="top-label"></span> <span class="font-semibold w-1/2 text-center flex-1">Operating Systems</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="os-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="os-top-picker" data-entity="1" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div> </div>
<canvas id="chart-os"></canvas> <canvas id="chart-os"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -90,9 +100,13 @@
</div> </div>
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col relative" id="language-container" style="height: 300px"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col relative" id="language-container" style="height: 300px">
<div class="self-center flex"> <div class="flex justify-between">
<span class="font-semibold mr-1">Languages</span> <div class="w-1/4 flex-1"></div>
<span id="language-top-label" class="top-label"></span> <span class="font-semibold w-1/2 text-center flex-1">Languages</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="language-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="language-top-picker" data-entity="3" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div> </div>
<canvas id="chart-language"></canvas> <canvas id="chart-language"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -103,9 +117,13 @@
</div> </div>
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="editor-container" style="height: 300px"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="editor-container" style="height: 300px">
<div class="self-center flex"> <div class="flex justify-between">
<span class="font-semibold mr-1">Editors</span> <div class="w-1/4 flex-1"></div>
<span id="editor-top-label" class="top-label"></span> <span class="font-semibold w-1/2 text-center flex-1">Editors</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="editor-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="editor-top-picker" data-entity="2" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div> </div>
<canvas id="chart-editor"></canvas> <canvas id="chart-editor"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -116,9 +134,13 @@
</div> </div>
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="machine-container" style="height: 300px"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="machine-container" style="height: 300px">
<div class="self-center flex"> <div class="flex justify-between">
<span class="font-semibold mr-1">Machines</span> <div class="w-1/4 flex-1"></div>
<span id="machine-top-label" class="top-label"></span> <span class="font-semibold w-1/2 text-center flex-1">Machines</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="machine-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="machine-top-picker" data-entity="4" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
</div>
</div> </div>
<canvas id="chart-machine"></canvas> <canvas id="chart-machine"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
@ -133,6 +155,19 @@
{{ template "footer.tpl.html" . }} {{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }} {{ template "foot.tpl.html" . }}
<script>
const languageColors = {{ .LanguageColors | json }}
const wakapiData = {}
wakapiData.projects = {{ .Projects | json }}
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
wakapiData.machines = {{ .Machines | json }}
</script>
<script src="assets/app.js"></script>
</body> </body>
</html> </html>